🤓

Next.js: dynamicIOとuse cacheにおけるDynamic APIsの実装を追ってみました

2024/12/23に公開

この記事はFindy Advent Calendar 2024 23日目の記事です。

みなさんこんにちは。
Our Journey with Cachingが公開されてから2ヶ月程が経ちますね。
use cacheディレクティブを使用するにはdynamicIOモードの利用が必要になりますが、このモードはいくつかの処理の振る舞いを変更します。

Dynamic APIsの振る舞いの変化

特にDynamic APIsは、v15から非同期処理になったばかりですが、dynamicIOモードにおいては更にSuspense境界内でPromiseを解決する必要があります。

例えば下記のようなコンポーネントをビルドしてみると

import { Map } from './map'

export default async function Page({ searchParams }) {
  const { lat, lng } = await searchParams;
  return (
    <Suspense fallback="loading your inbox...">
      <Map lat={lat} lng={lng}>
    </Suspense>
  )
}

----

async function Map({ lat, lng }) {
  const mapData = await fetch(`https://...?lat=${lat}&lng=${lng}`)
  return drawMap(mapData)
}

以下のエラーが発生します。

Error: Route "/": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. We don't have the exact line number added to error messages yet but you can see which component in the stack below. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense

エラーを解決するには、searchParamsのPromiseをSuspense境界内で解決する必要があります。

import { Map } from './map'

export default async function Page({ searchParams }) {
  const coords = searchParams.then(sp => ({ lat: sp.lat, lng: sp.lng }))
  return (
    <Suspense fallback="loading your inbox...">
      <Map coord={coords}>
    </Suspense>
  )
}

----

async function Map({ coords }) {
  const { lat, lng } = await coords
  const mapData = await fetch(`https://...?lat=${lat}&lng=${lng}`)
  return drawMap(mapData)
}

このエラーの背景にある仕組みと、dynamicIOモードでのDynamic APIsの挙動について気になったので、Next.jsの実装を追ってみました。

makeHangingPromise()と<Suspense>

Dynamic APIsを同期処理から非同期処理に変更したタイミングでmakeHangingPromise()なるインターナル関数が追加されたことが確認できます。
https://github.com/vercel/next.js/pull/68812/files#diff-dbf0a207722ae4e1100ddd03dcf8aeaec2a397dc612f2a6df50a34ca19ba5b78R10
のちにこちらのPRでその実装が追加されました。makeHangingPromise()の実装はこのようにいたってシンプルです。
https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/dynamic-rendering-utils.ts#L1-L32
また同じPRでmakeHangingPromise()がDynamic APIsの処理に組み込まれたことも確認できます。
例えば下記はsearchParamsの実装ですが、makeAbortingExoticSearchParams()は内部でmakeHangingPromise()を呼び出しており、これはdynamicIOモードでのみ呼び出されることが確認できます。
https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/request/search-params.ts#L133-L136

結局のところ

このmakeHangingPromise()が一体何なのかについてですが、「Next.jsがページをprerenderする際に、dynamicな部分をスキップするためのシグナル」 と筆者は理解しました。つまりはdynamicIOモードにおいてはDynamic APIsに依存する処理(描画)の結果はprerenderには含まれないようになっているということです。
そしてprerender中のmakeHangingPromise()の捕捉とスキップされたdynamicな部分のstream管理のために<Suspense>が必要になったと読み解けます。

ここまででdynamicIOにおいてDynamic APIsの振る舞いが変わった理由を把握することができました。
ついでにuse cacheディレクティブ配下でDynamic APIsを使用した場合はどのようになるのかも気になったのでそちらについても確認していきます。

use cacheとの併用時

冒頭でお見せしたコードにuse cacheを追加してビルドしてみます(今回の例ではファイルのトップレベルでuse cacheディレクティブを宣言していますが、searchParamsの解決よりも先に宣言していれば関数のトップレベルで宣言しても同じ結果になります)。

'use cache'
import { Map } from './map'

export default async function Page({ searchParams }) {
  const coords = searchParams.then(sp => ({ lat: sp.lat, lng: sp.lng }))
  return (
    <Suspense fallback="loading your inbox...">
      <Map coord={coords}>
    </Suspense>
  )
}

----

async function Map({ coords }) {
  const { lat, lng } = await coords
  const mapData = await fetch(`https://...?lat=${lat}&lng=${lng}`)
  return drawMap(mapData)
}

すると数十秒経ったのちに下記のエラーとなりビルドが失敗します。

Error: Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".

この制限はuse-cache-wrapper.ts内で実装されており、50秒のタイムアウトが設定されていることがわかります。use cache内でのDynamic APIsの解決はできないようになっているようです。
https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/use-cache/use-cache-wrapper.ts#L313-L325

Dynamic APIsを解決して得られるデータを、キャッシュしたい関数の引数やクロージャー[1]として使用しない限りは、リクエスト固有のデータやそれを使用したデータ取得結果が誤ってキャッシュされることはなさそうです!

ちなみにですが実はこのファイルで定義されているcache()がuse cacheディレクティブによるキャッシュ生成処理の実装部分になります。
https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/use-cache/use-cache-wrapper.ts#L451
詳しくはakfm_sato氏のDynamic IOの成り立ちと"use cache"の深層が大変参考になります。

実装を追ってみて

dynamicIO関連の実装は興味深く、本稿の範囲を超えますが、そのコアとも言えるAsyncLocalStorageを使ったコンテキスト管理は読み応えのある実装でした(AsyncLocalStorageによる実装はdynamicIO以前から存在しています)。

  • 様々なコンテキスト(WorkStore、CacheStore、PrerenderStoreなど)を独立して管理している[2]
  • runInCleanSnapshot()によって、キャッシュ生成時にリクエスト固有のデータ(cookies、headersなど)を含まない安全なコンテキストを作成している[3]
  • makeHangingPromise()はこれらのコンテキスト管理を基に実装され、キャッシュ生成処理にも組み込まれている[4][5]

これらの詳細や今回の記事では触れられなかった部分も含め、またの機会に記事化できればと思います。

脚注
  1. https://nextjs.org/blog/our-journey-with-caching#cached-functions ↩︎

  2. https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/app-render/work-unit-async-storage.external.ts ↩︎

  3. https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/use-cache/use-cache-wrapper.ts#L70-L90 ↩︎

  4. https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/use-cache/use-cache-wrapper.ts#L599-L602 ↩︎

  5. https://github.com/vercel/next.js/blob/b730b9c0d20e7a950615ccbe62277f9e1d376462/packages/next/src/server/use-cache/use-cache-wrapper.ts#L655-L658 ↩︎

Discussion