SWR を完全に理解するスクラップ

ドキュメント

コードリーディングする

まずはいつもお世話になっている useSWR と SWRConfig あたりから
useSWRHandler が中身。
export default withArgs<SWRHook>(useSWRHandler)
withArgs はこれ。型の上書き用の関数で中身はデフォルトコンフィグと引数の Options をマージしたり middleware 適用したり

return しているもの
return {
mutate: boundMutate,
get data() {
stateDependencies.data = true
return returnedData
},
get error() {
stateDependencies.error = true
return error
},
get isValidating() {
stateDependencies.isValidating = true
return isValidating
},
get isLoading() {
stateDependencies.isLoading = true
return isLoading
}
} as SWRResponse<Data, Error>

returnedData はこんなん
const returnedData = keepPreviousData
? isUndefined(cachedData)
? laggyDataRef.current
: cachedData
: data
data はこんな感じ
const fallback = isUndefined(fallbackData)
? config.fallback[key]
: fallbackData
const data = isUndefined(cachedData) ? fallback : cachedData
keepPreviousData は
新しいキーに対するデータがロードされるまで以前のキーのデータを返すかどうか (詳細)
よりよいユーザー体験のために以前のデータを返す
キー入力に応じたリアルタイム検索など継続的なユーザーアクションをベースにしたデータ取得を行う時、以前のデータを維持することによりユーザー体験を改善できます。keepPreviousData はこれを可能にするオプションです。これはシンプルな検索の例です。

これ data cached なものか fallback しか参照してなさそうだけど最初のものはどこから取ってきてるんやろか

revalidate という 関数の中で fetcher を使ってリクエスト作ってる。
リクエスト後に setCache が実行されている
const revalidate = useCallback(
async (revalidateOpts?: RevalidatorOptions): Promise<boolean> => {
const currentFetcher = fetcherRef.current
if (
!key ||
!currentFetcher ||
unmountedRef.current ||
getConfig().isPaused()
) {
return false
}
let newData: Data
let startAt: number
let loading = true
const opts = revalidateOpts || {}
// If there is no ongoing concurrent request, or `dedupe` is not set, a
// new request should be initiated.
const shouldStartNewRequest = !FETCH[key] || !opts.dedupe
/*
For React 17
Do unmount check for calls:
If key has changed during the revalidation, or the component has been
unmounted, old dispatch and old event callbacks should not take any
effect
For React 18
only check if key has changed
https://github.com/reactwg/react-18/discussions/82
*/
const callbackSafeguard = () => {
if (IS_REACT_LEGACY) {
return (
!unmountedRef.current &&
key === keyRef.current &&
initialMountedRef.current
)
}
return key === keyRef.current
}
// The final state object when the request finishes.
const finalState: State<Data, Error> = {
isValidating: false,
isLoading: false
}
const finishRequestAndUpdateState = () => {
setCache(finalState)
}
const cleanupState = () => {
// Check if it's still the same request before deleting it.
const requestInfo = FETCH[key]
if (requestInfo && requestInfo[1] === startAt) {
delete FETCH[key]
}
}
// Start fetching. Change the `isValidating` state, update the cache.
const initialState: State<Data, Error> = { isValidating: true }
// It is in the `isLoading` state, if and only if there is no cached data.
// This bypasses fallback data and laggy data.
if (isUndefined(getCache().data)) {
initialState.isLoading = true
}
try {
if (shouldStartNewRequest) {
setCache(initialState)
// If no cache is being rendered currently (it shows a blank page),
// we trigger the loading slow event.
if (config.loadingTimeout && isUndefined(getCache().data)) {
setTimeout(() => {
if (loading && callbackSafeguard()) {
getConfig().onLoadingSlow(key, config)
}
}, config.loadingTimeout)
}
// Start the request and save the timestamp.
// Key must be truthy if entering here.
FETCH[key] = [
currentFetcher(fnArg as DefinitelyTruthy<Key>),
getTimestamp()
]
}
// Wait until the ongoing request is done. Deduplication is also
// considered here.
;[newData, startAt] = FETCH[key]
newData = await newData
if (shouldStartNewRequest) {
// If the request isn't interrupted, clean it up after the
// deduplication interval.
setTimeout(cleanupState, config.dedupingInterval)
}
// If there're other ongoing request(s), started after the current one,
// we need to ignore the current one to avoid possible race conditions:
// req1------------------>res1 (current one)
// req2---------------->res2
// the request that fired later will always be kept.
// The timestamp maybe be `undefined` or a number
if (!FETCH[key] || FETCH[key][1] !== startAt) {
if (shouldStartNewRequest) {
if (callbackSafeguard()) {
getConfig().onDiscarded(key)
}
}
return false
}
// Clear error.
finalState.error = UNDEFINED
// If there're other mutations(s), that overlapped with the current revalidation:
// case 1:
// req------------------>res
// mutate------>end
// case 2:
// req------------>res
// mutate------>end
// case 3:
// req------------------>res
// mutate-------...---------->
// we have to ignore the revalidation result (res) because it's no longer fresh.
// meanwhile, a new revalidation should be triggered when the mutation ends.
const mutationInfo = MUTATION[key]
if (
!isUndefined(mutationInfo) &&
// case 1
(startAt <= mutationInfo[0] ||
// case 2
startAt <= mutationInfo[1] ||
// case 3
mutationInfo[1] === 0)
) {
finishRequestAndUpdateState()
if (shouldStartNewRequest) {
if (callbackSafeguard()) {
getConfig().onDiscarded(key)
}
}
return false
}
// Deep compare with the latest state to avoid extra re-renders.
// For local state, compare and assign.
const cacheData = getCache().data
// Since the compare fn could be custom fn
// cacheData might be different from newData even when compare fn returns True
finalState.data = compare(cacheData, newData) ? cacheData : newData
// Trigger the successful callback if it's the original request.
if (shouldStartNewRequest) {
if (callbackSafeguard()) {
getConfig().onSuccess(newData, key, config)
}
}
} catch (err: any) {
cleanupState()
const currentConfig = getConfig()
const { shouldRetryOnError } = currentConfig
// Not paused, we continue handling the error. Otherwise, discard it.
if (!currentConfig.isPaused()) {
// Get a new error, don't use deep comparison for errors.
finalState.error = err as Error
// Error event and retry logic. Only for the actual request, not
// deduped ones.
if (shouldStartNewRequest && callbackSafeguard()) {
currentConfig.onError(err, key, currentConfig)
if (
shouldRetryOnError === true ||
(isFunction(shouldRetryOnError) &&
shouldRetryOnError(err as Error))
) {
if (isActive()) {
// If it's inactive, stop. It will auto-revalidate when
// refocusing or reconnecting.
// When retrying, deduplication is always enabled.
currentConfig.onErrorRetry(
err,
key,
currentConfig,
revalidate,
{
retryCount: (opts.retryCount || 0) + 1,
dedupe: true
}
)
}
}
}
}
}
// Mark loading as stopped.
loading = false
// Update the current hook's state.
finishRequestAndUpdateState()
return true
},
// `setState` is immutable, and `eventsCallback`, `fnArg`, and
// `keyValidating` are depending on `key`, so we can exclude them from
// the deps array.
//
// FIXME:
// `fn` and `config` might be changed during the lifecycle,
// but they might be changed every render like this.
// `useSWR('key', () => fetch('/api/'), { suspense: true })`
// So we omit the values from the deps array
// even though it might cause unexpected behaviors.
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, cache]
)

createCacheHelper はここ
export const createCacheHelper = <Data = any, T = State<Data, any>>(
cache: Cache,
key: Key
) => {
const state = SWRGlobalState.get(cache) as GlobalState
return [
// Getter
() => (cache.get(key) || EMPTY_CACHE) as T,
// Setter
(info: T) => {
const prev = cache.get(key)
state[5](key as string, mergeObjects(prev, info), prev || EMPTY_CACHE)
},
// Subscriber
state[6]
] as const
}
useSWR の中では subscriber をこんな感じで使っている
// Get the current state that SWR should return.
const cached = useSyncExternalStore(
useCallback(
(callback: () => void) =>
subscribeCache(
key,
(prev: State<Data, any>, current: State<Data, any>) => {
if (!isEqual(prev, current)) callback()
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[cache, key]
),
getSnapshot,
getSnapshot
)

useSyncExternalStore は外部の状態管理を購読できる React の API

revalidate 関数は useIsomorphicLayoutEffect の中から呼ばれている。
shouldDoInitialRevalidation の分岐のところで最初の fetch をおこなっている模様。
// After mounted or key changed.
useIsomorphicLayoutEffect(() => {
if (!key) return
const softRevalidate = revalidate.bind(UNDEFINED, WITH_DEDUPE)
// Expose revalidators to global event listeners. So we can trigger
// revalidation from the outside.
let nextFocusRevalidatedAt = 0
const onRevalidate = (type: RevalidateEvent) => {
if (type == revalidateEvents.FOCUS_EVENT) {
const now = Date.now()
if (
getConfig().revalidateOnFocus &&
now > nextFocusRevalidatedAt &&
isActive()
) {
nextFocusRevalidatedAt = now + getConfig().focusThrottleInterval
softRevalidate()
}
} else if (type == revalidateEvents.RECONNECT_EVENT) {
if (getConfig().revalidateOnReconnect && isActive()) {
softRevalidate()
}
} else if (type == revalidateEvents.MUTATE_EVENT) {
return revalidate()
}
return
}
const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate)
// Mark the component as mounted and update corresponding refs.
unmountedRef.current = false
keyRef.current = key
initialMountedRef.current = true
// Keep the original key in the cache.
setCache({ _k: fnArg })
// Trigger a revalidation.
if (shouldDoInitialRevalidation) {
if (isUndefined(data) || IS_SERVER) {
// Revalidate immediately.
softRevalidate()
} else {
// Delay the revalidate if we have data to return so we won't block
// rendering.
rAF(softRevalidate)
}
}
return () => {
// Mark it as unmounted.
unmountedRef.current = true
unsubEvents()
}
}, [key])

ちなみに useIsomorphicLayoutEffect は名前の通りサーバーでも動く useLayoutEffect

FETCH は SWRGlobalState なるものから取得してる
const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get(
cache
) as GlobalState
中身はただの WeakMap だった
import type { Cache, GlobalState } from '../types'
// Global state used to deduplicate requests and store listeners
export const SWRGlobalState = new WeakMap<Cache, GlobalState>()
オブジェクト(この場合はリクエストで取ってきたデータ)が破棄されたらメモリも解放する目的っぽい

GlobalState の型
export type GlobalState = [
Record<string, RevalidateCallback[]>, // EVENT_REVALIDATORS
Record<string, [number, number]>, // MUTATION: [ts, end_ts]
Record<string, [any, number]>, // FETCH: [data, ts]
Record<string, FetcherResponse<any>>, // PRELOAD
ScopedMutator, // Mutator
(key: string, value: any, prev: any) => void, // Setter
(key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber
]

なんで完全に理解しようと思ったか
- 自分が携わっているプロダクトで「ページ遷移してリクエストが終わる前に他のページ遷移するとめっちゃ重くなる」というバグが再現した
- リクエスト関連なのは確かっぽいので、おそらく SWR 周りで何か起きているんだろうと推測
- 色々バグの元凶を探ってみたが state 無限に update されたりリクエストが飛びまくってるとかは観測できず
- ちょっと興味あったし SWR のコード読んでみるかと思い立つ