Skip to content

Instantly share code, notes, and snippets.

@promer94
Created December 18, 2019 09:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save promer94/33fcd94cc219969c279081d0aa705251 to your computer and use it in GitHub Desktop.
Save promer94/33fcd94cc219969c279081d0aa705251 to your computer and use it in GitHub Desktop.
swr
import {
useEffect,
useLayoutEffect,
useRef,
useContext,
useCallback,
useReducer
} from 'react'
import deepEqual from 'fast-deep-equal'
import {
keyInterface,
ConfigInterface,
RevalidateOptionInterface,
updaterInterface,
triggerInterface,
mutateInterface,
broadcastStateInterface,
responseInterface,
fetcherFn,
reducerType,
actionType
} from './types'
import defaultConfig, {
CONCURRENT_PROMISES,
CONCURRENT_PROMISES_TS,
FOCUS_REVALIDATORS,
CACHE_REVALIDATORS,
MUTATION_TS,
cacheGet,
cacheSet
} from './config'
import SWRConfigContext from './swr-config-context'
import isDocumentVisible from './libs/is-document-visible'
import throttle from './libs/throttle'
import hash from './libs/hash'
const IS_SERVER = typeof window === 'undefined'
// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// 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 trigger: triggerInterface = (_key, shouldRevalidate = true) => {
const [key] = getKeyArgs(_key)
if (!key) return
const updaters = CACHE_REVALIDATORS[key]
if (key && updaters) {
const currentData = cacheGet(key)
const currentError = cacheGet(getErrorKey(key))
for (let i = 0; i < updaters.length; ++i) {
updaters[i](shouldRevalidate, currentData, currentError, true)
}
}
}
const broadcastState: broadcastStateInterface = (key, data, error) => {
const updaters = CACHE_REVALIDATORS[key]
if (key && updaters) {
for (let i = 0; i < updaters.length; ++i) {
updaters[i](false, data, error)
}
}
}
const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => {
const [key] = getKeyArgs(_key)
if (!key) return
// update timestamp
MUTATION_TS[key] = Date.now() - 1
let data, error
if (_data && typeof _data.then === 'function') {
// `_data` is a promise
try {
data = await _data
} catch (err) {
error = err
}
} else {
data = _data
if (typeof shouldRevalidate === 'undefined') {
// if it's a sync mutation, we trigger the revalidation by default
// because in most cases it's a local mutation
shouldRevalidate = true
}
}
if (typeof data !== 'undefined') {
// update cached data
cacheSet(key, data)
}
// update existing SWR Hooks' state
const updaters = CACHE_REVALIDATORS[key]
if (updaters) {
for (let i = 0; i < updaters.length; ++i) {
updaters[i](!!shouldRevalidate, data, error, true)
}
}
}
function mergeState(state, payload) {
return { ...state, ...payload }
}
function useSWR<Data = any, Error = any>(
key: keyInterface
): responseInterface<Data, Error>
function useSWR<Data = any, Error = any>(
key: keyInterface,
config?: ConfigInterface<Data, Error>
): responseInterface<Data, Error>
function useSWR<Data = any, Error = any>(
key: keyInterface,
fn?: fetcherFn<Data>,
config?: ConfigInterface<Data, Error>
): responseInterface<Data, Error>
function useSWR<Data = any, Error = any>(
...args
): responseInterface<Data, Error> {
let _key: keyInterface,
fn: fetcherFn<Data> | undefined,
config: ConfigInterface<Data, Error> = {}
if (args.length >= 1) {
_key = args[0]
}
if (args.length > 2) {
fn = args[1]
config = args[2]
} else {
if (typeof args[1] === 'function') {
fn = args[1]
} else if (typeof args[1] === 'object') {
config = args[1]
}
}
// 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)
config = Object.assign(
{},
defaultConfig,
useContext(SWRConfigContext),
config
)
if (typeof fn === 'undefined') {
// use a global fetcher
fn = config.fetcher
}
const initialData = cacheGet(key) || config.initialData
const initialError = cacheGet(keyErr)
let [state, dispatch] = useReducer<reducerType<Data, Error>>(mergeState, {
data: initialData,
error: initialError,
isValidating: false
})
// error ref inside revalidate (is last request errored?)
const unmountedRef = useRef(false)
const keyRef = useRef(key)
const dataRef = useRef(initialData)
const errorRef = useRef(initialError)
const intervalRef = useRef(config.refreshInterval)
// start a revalidation
const revalidate = useCallback(
async (
revalidateOpts: RevalidateOptionInterface = {}
): Promise<boolean> => {
if (!key || !fn) return false
if (unmountedRef.current) return false
revalidateOpts = Object.assign({ dedupe: false }, revalidateOpts)
let loading = true
let shouldDeduping =
typeof CONCURRENT_PROMISES[key] !== 'undefined' && revalidateOpts.dedupe
// start fetching
try {
dispatch({
isValidating: true
})
let newData
let startAt
if (shouldDeduping) {
// there's already an ongoing request,
// this one needs to be deduplicated.
startAt = CONCURRENT_PROMISES_TS[key]
newData = await CONCURRENT_PROMISES[key]
} else {
// if not deduping the request (hard revalidate) but
// there're other ongoing request(s) at the same time,
// we need to ignore the other result(s) to avoid
// possible race conditions:
// req1------------------>res1
// req2-------->res2
// in that case, the second response should not be overridden
// by the first one.
if (CONCURRENT_PROMISES[key]) {
// we can mark it as a mutation to ignore
// all requests which are fired before this one
MUTATION_TS[key] = Date.now() - 1
}
// if no cache being rendered currently (it shows a blank page),
// we trigger the loading slow event.
if (config.loadingTimeout && !cacheGet(key)) {
setTimeout(() => {
if (loading) config.onLoadingSlow(key, config)
}, config.loadingTimeout)
}
if (fnArgs !== null) {
CONCURRENT_PROMISES[key] = fn(...fnArgs)
} else {
CONCURRENT_PROMISES[key] = fn(key)
}
CONCURRENT_PROMISES_TS[key] = startAt = Date.now()
setTimeout(() => {
delete CONCURRENT_PROMISES[key]
delete CONCURRENT_PROMISES_TS[key]
}, config.dedupingInterval)
newData = await CONCURRENT_PROMISES[key]
// trigger the success event,
// only do this for the original request.
config.onSuccess(newData, key, config)
}
// if the revalidation happened earlier than the local mutation,
// we have to ignore the result because it could override.
// meanwhile, a new revalidation should be triggered by the mutation.
if (MUTATION_TS[key] && startAt <= MUTATION_TS[key]) {
dispatch({ isValidating: false })
return false
}
cacheSet(key, newData)
cacheSet(keyErr, undefined)
keyRef.current = key
// new state for the reducer
const newState: actionType<Data, Error> = {
isValidating: false
}
if (typeof errorRef.current !== 'undefined') {
// we don't have an error
newState.error = undefined
errorRef.current = undefined
}
if (deepEqual(dataRef.current, newData)) {
// deep compare to avoid extra re-render
// do nothing
} else {
// data changed
newState.data = newData
dataRef.current = newData
}
// merge the new state
dispatch(newState)
if (!shouldDeduping) {
// also update other hooks
broadcastState(key, newData, undefined)
}
} catch (err) {
delete CONCURRENT_PROMISES[key]
delete CONCURRENT_PROMISES_TS[key]
cacheSet(keyErr, err)
keyRef.current = key
// get a new error
// don't use deep equal for errors
if (errorRef.current !== err) {
errorRef.current = err
// we keep the stale data
dispatch({
isValidating: false,
error: err
})
if (!shouldDeduping) {
// also broadcast to update other hooks
broadcastState(key, undefined, err)
}
}
// events and retry
config.onError(err, key, config)
if (config.shouldRetryOnError) {
// when retrying, we always enable deduping
const retryCount = (revalidateOpts.retryCount || 0) + 1
config.onErrorRetry(
err,
key,
config,
revalidate,
Object.assign({ dedupe: true }, revalidateOpts, { retryCount })
)
}
}
loading = false
return true
},
[key]
)
// mounted (client side rendering)
useIsomorphicLayoutEffect(() => {
if (!key) return undefined
// after `key` updates, we need to mark it as mounted
unmountedRef.current = false
// after the component is mounted (hydrated),
// we need to update the data from the cache
// and trigger a revalidation
const currentHookData = dataRef.current
const latestKeyedData = cacheGet(key) || config.initialData
// update the state if the key changed or cache updated
if (
keyRef.current !== key ||
!deepEqual(currentHookData, latestKeyedData)
) {
dispatch({ data: latestKeyedData })
dataRef.current = latestKeyedData
keyRef.current = key
}
// revalidate with deduping
const softRevalidate = () => revalidate({ dedupe: true })
// trigger a revalidation
if (
typeof latestKeyedData !== 'undefined' &&
!IS_SERVER &&
window['requestIdleCallback']
) {
// delay revalidate if there's cache
// to not block the rendering
window['requestIdleCallback'](softRevalidate)
} else {
softRevalidate()
}
// whenever the window gets focused, revalidate
let onFocus
if (config.revalidateOnFocus) {
// throttle: avoid being called twice from both listeners
// and tabs being switched quickly
onFocus = throttle(softRevalidate, config.focusThrottleInterval)
if (!FOCUS_REVALIDATORS[key]) {
FOCUS_REVALIDATORS[key] = [onFocus]
} else {
FOCUS_REVALIDATORS[key].push(onFocus)
}
}
// register global cache update listener
const onUpdate: updaterInterface<Data, Error> = (
shouldRevalidate = true,
updatedData,
updatedError,
dedupe = true
) => {
// update hook state
const newState: actionType<Data, Error> = {}
if (
typeof updatedData !== 'undefined' &&
!deepEqual(dataRef.current, updatedData)
) {
newState.data = updatedData
dataRef.current = updatedData
}
// always update error
// because it can be `undefined`
if (errorRef.current !== updatedError) {
newState.error = updatedError
errorRef.current = updatedError
}
dispatch(newState)
keyRef.current = key
if (shouldRevalidate) {
if (dedupe) {
return softRevalidate()
} else {
return revalidate()
}
}
return false
}
// add updater to listeners
if (!CACHE_REVALIDATORS[key]) {
CACHE_REVALIDATORS[key] = [onUpdate]
} else {
CACHE_REVALIDATORS[key].push(onUpdate)
}
// set up polling
let timeout = null
if (intervalRef.current) {
const tick = async () => {
if (
!errorRef.current &&
(config.refreshWhenHidden || isDocumentVisible())
) {
// only revalidate when the page is visible
// if API request errored, we stop polling in this round
// and let the error retry function handle it
await softRevalidate()
}
const interval = intervalRef.current
timeout = setTimeout(tick, interval)
}
timeout = setTimeout(tick, intervalRef.current)
}
return () => {
// cleanup
dispatch = () => null
// mark it as unmounted
unmountedRef.current = true
if (onFocus && FOCUS_REVALIDATORS[key]) {
const revalidators = FOCUS_REVALIDATORS[key]
const index = revalidators.indexOf(onFocus)
if (index >= 0) {
// 10x faster than splice
// https://jsperf.com/array-remove-by-index
revalidators[index] = revalidators[revalidators.length - 1]
revalidators.pop()
}
}
if (CACHE_REVALIDATORS[key]) {
const revalidators = CACHE_REVALIDATORS[key]
const index = revalidators.indexOf(onUpdate)
if (index >= 0) {
revalidators[index] = revalidators[revalidators.length - 1]
revalidators.pop()
}
}
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [key, revalidate])
useIsomorphicLayoutEffect(() => {
intervalRef.current = config.refreshInterval
}, [config.refreshInterval])
// suspense
if (config.suspense) {
if (IS_SERVER)
throw new Error('Suspense on server side is not yet supported!')
// in suspense mode, we can't return empty state
// (it should be suspended)
// try to get data and error from cache
let latestData = cacheGet(key)
let latestError = cacheGet(keyErr)
if (
typeof latestData === 'undefined' &&
typeof latestError === 'undefined'
) {
// need to start the request if it hasn't
if (!CONCURRENT_PROMISES[key]) {
// trigger revalidate immediately
// to get the promise
revalidate()
}
if (
CONCURRENT_PROMISES[key] &&
typeof CONCURRENT_PROMISES[key].then === 'function'
) {
// if it is a promise
throw CONCURRENT_PROMISES[key]
}
// it's a value, return it directly (override)
latestData = CONCURRENT_PROMISES[key]
}
if (typeof latestData === 'undefined' && latestError) {
// in suspense mode, throw error if there's no content
throw latestError
}
// return the latest data / error from cache
// in case `key` has changed
return {
error: latestError,
data: latestData,
revalidate,
isValidating: state.isValidating
}
}
return {
// `key` might be changed in the upcoming hook re-render,
// but the previous state will stay
// so we need to match the latest key and data (fallback to `initialData`)
error: keyRef.current === key ? state.error : initialError,
data: keyRef.current === key ? state.data : initialData,
revalidate, // handler
isValidating: state.isValidating
}
}
const SWRConfig = SWRConfigContext.Provider
export { trigger, mutate, SWRConfig }
export default useSWR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment