Open18

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

seyaseya

ドキュメント
https://swr.vercel.app/ja

seyaseya

コードリーディングする

https://github.com/vercel/swr

seyaseya

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>

seyaseya

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 はこれを可能にするオプションです。これはシンプルな検索の例です。

https://swr.vercel.app/ja/docs/advanced/understanding#return-previous-data-for-better-ux

seyaseya

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

seyaseya

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]
  )
seyaseya

createCacheHelper はここ

https://github.dev/vercel/swr/blob/main/_internal/utils/helper.ts#L31

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
  )
seyaseya

revalidate 関数は useIsomorphicLayoutEffect の中から呼ばれている。
shouldDoInitialRevalidation の分岐のところで最初の fetch をおこなっている模様。
https://github.dev/vercel/swr/blob/main/core/use-swr.ts#L486

  // 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])
seyaseya

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>()

https://github.dev/vercel/swr/blob/main/_internal/utils/global-state.ts#L5

オブジェクト(この場合はリクエストで取ってきたデータ)が破棄されたらメモリも解放する目的っぽい

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/WeakMap

seyaseya

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
]
seyaseya

なんで完全に理解しようと思ったか

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