Next.js revalidatePath/revalidateTagの仕組み
以前、Next.jsの遷移の実装やRouter Cacheの実装について筆者が調べたことを記事にしました。
本稿はこれらの記事同様に、revalidatePath
/revalidateTag
の仕組みについて筆者が興味のままにNext.jsの実装を調査したことについてまとめたものになります。直接的にApp Routerを利用する開発者に役立つかどうかは分かりませんが、ある程度裏側でこういうことが起きてるんだという参考になれば幸いです。
前提条件
App Routerの機能であるrevalidatePath
/revalidateTag
について触れるため、以下の機能について理解してる必要があります。本稿ではこれらの機能について改めて詳解しないので、必要に応じて各ドキュメントをご参照ください。
Next.jsのデバッグ
筆者のNext.jsの実装を調査する時のTipsです。興味のある方は参考にしてください。
ローカル環境でNext.jsをbuildする
forkしたNext.jsのリポジトリを自分の環境に落とし、気になるところにconsole.log
を仕込むなど適宜修正buildして調査を行います。buildしたNext.jsをローカルアプリケーションで利用する方法については以下のドキュメントにまとまっています。
上記手順とは異なりますが、筆者はローカルでNext.jsリポジトリをpnpm build
した後、以下のようにpackages/next
だけpnpm create next-app
したアプリケーションで利用する形をとっています。
$ pnpm add {nextjs_git_path}/next.js/packages/next
NEXT_PRIVATE_DEBUG_CACHE
NEXT_PRIVATE_DEBUG_CACHE
という環境変数を設定するとNext.js内部のデバッグ出力が有効になるので、NEXT_PRIVATE_DEBUG_CACHE=1 next start
のようにして実行するだけでも色々な情報が出力されて内部実装のイメージが掴みやすくなります。
Next.jsのAPI定義
Next.jsのリポジトリはモノリポ構成になっており、本体であるnext
パッケージは/packages/next
にあります。
revalidatePath
/revalidateTag
上記デバッグ方法を駆使しながら、revalidatePath
/revalidateTag
実行時の挙動について調べていきたいと思いますが、その前に公式ドキュメントを確認します。ドキュメントでは以下のような説明がなされています。
revalidatePath
only invalidates the cache when the included path is next visited.
(revalidatePath
は、含まれるパスへ次に訪問されたときにのみキャッシュを無効にする。)
これらの関数を呼び出した後に、ページに再訪問することでキャッシュのinvalidateが行われる、とあります。つまりこれらの関数は呼び出し時のrevalidate情報をサーバー側に保存し、再訪問時にそのrevalidate情報からキャッシュの鮮度を判断してinvalidateを行っている、というような実装であることが推測されます。
revalidatePath
のデバッグ出力
実際にNEXT_PRIVATE_DEBUG_CACHE=1
を設定し、以下のような簡易的なサンプルで実行してrevalidatePath
のデバッグ出力を確認してみます。
// app/page.tsx
import { revalidatePath } from "next/cache";
export default async function Page() {
async function revalidate() {
"use server";
revalidatePath("/");
}
const res = await fetch("https://dummyjson.com/products/1", {
next: { tags: ["products"] },
}).then((res) => res.json());
return (
<>
<h1>Hello, Next.js!</h1>
<code>
<pre>{JSON.stringify(res, null, 2)}</pre>
</code>
<form action={revalidate}>
<button type="submit">revalidate</button>
</form>
</>
);
}
export const dynamic = "force-dynamic";
using filesystem cache handler
not using memory store for fetch cache
revalidateTag _N_T_/
Updated tags manifest { version: 1, items: { '_N_T_/': { revalidatedAt: 1710837210692 } } }
出力にもある通り、Next.jsのキャッシュ永続化先はデフォルトだとファイルシステムキャッシュとなっていることがわかります。これは公式ドキュメントにもあるので想定通りです。
In Next.js, the default cache handler for the Pages and App Router uses the filesystem cache.
(Next.jsでは、PagesとApp Routerのデフォルトのキャッシュハンドラはファイルシステムキャッシュを使用します。)
revalidatePath("/")
を呼んだのに出力を見るとpathではなくrevalidateTag _N_T_/
となっているのがわかります。また、最後の行を見るとどうやらmanifestを更新してるような出力が見受けられます。この辺りを頭に入れた上で、実際の実装を探っていきましょう。
revalidatePath
/revalidateTag
の定義から処理を追う
revalidatePath
/revalidateTag
の定義はnext/cache
にあります。
これ自体jsファイルでbuild済みファイルであるdist
以下を参照しているので、build前の定義である以下を参照します。
revalidatePath
とrevalidateTag
の実装の中身を見ていくと、おおよそ大部分の処理が共通化されており、実際にはrevalidatePath
は_N_T_
付のtagをnormalizeしてrevalidateTag
を呼び出しているだけであることがわかります。NEXT_PRIVATE_DEBUG_CACHE=1
にした時の出力結果とも齟齬はありません。
共通化された処理であるrevalidate
関数前半部分はチェックやトラッキング用の処理などで、主要な処理は後半部分の以下部分になります。
store.pendingRevalidates[tag]
にincrementalCache.revalidateTag(tag)
の処理自体を格納していることがわかります。このincrementalCache.revalidateTag(tag)
こそが、revalidate情報を永続化する処理であると推測されます。この処理自体は非同期処理ですがここではawait
せず、store
に詰めることで後続処理のServer Actions handler内などでawait
されます。
このincrementalCache.revalidateTag(tag)
は以下で定義されています。
筆者が調べた限り__NEXT_INCREMENTAL_CACHE_IPC_PORT
などは普通にnext start
しても未定義なので、これらは実質的にVercel専用ロジックではないかと推測されます。なので最後のreturn this.cacheHandler?.revalidateTag?.(tag)
がセルフホスティグやローカル環境で実行される処理になります。this.cacheHandler
はCustom Next.js Cache Handlerによってカスタマイズすることができますが、前述の通りデフォルトではファイルシステムキャッシュが利用されるので以下の実装が利用されます。
ここではthis.tagsManifestPath
(.next/cache/fetch-cache/tags-manifest.json
)に対し、対象タグのrevalidatedAt
を更新してるだけであることがわかります。以下はtags-manifest.json
の出力例で、next start
後にrevalidatePath("/cache_debug")
とrevalidateTag("products")
を実行した後の状態です。
{
"version": 1,
"items": {
"_N_T_/cache_debug": { "revalidatedAt": 1711279116900 },
"products": { "revalidatedAt": 1711279247881 }
}
}
これらのことから、
これらの関数は呼び出し時のrevalidate情報をサーバー側に保存し、再訪問時にそのrevalidate情報からキャッシュの鮮度を判断してinvalidateを行っている
という筆者の推測通りな実装であるように見受けられました。次に各キャッシュデータのtagとmanifestの比較処理を追ってみます。
キャッシュデータのtag
キャッシュデータのtagがどこで保存されているのか気になるところですが、闇雲に生成してそうな処理をさがしても非常に膨大な処理を彷徨うことになります。そこで、
これらの関数は呼び出し時のrevalidate情報をサーバー側に保存し、再訪問時にそのrevalidate情報からキャッシュの鮮度を判断してinvalidateを行っている
という推測の元.next/cache/fetch-cache/tags-manifest.json
を読み込み利用している箇所を中心に利用箇所を調査しました。その結果、file-system-cache.ts
の以下の部分がそれらしき処理をしているように見受けられました。
それぞれdata?.value?.kind
という値がPAGE
かFETCH
かで分岐しています。PAGE
はFull Route Cache、FETCH
はData Cacheを表しています。
Full Route Cacheのtagはdata.value.headers?.[NEXT_CACHE_TAGS_HEADER]
より参照されます。これは内部的には以下の処理で付与されます。
このfetchTags
は.next/server
配下の[pathname].meta
より取得されます。このファイルはnext build
時に生成され、ページに関連するタグ情報を格納しています。
Data Cacheの方ではwasRevalidated
の判定にtags
とsoftTags
を利用しています。fetchで明示的に指定したtagがtags
、呼び出し元のパス名称に_N_T_
を付与したtagがsoftTags
に含まれています。取得したキャッシュデータであるdata
のlastModified
(ファイルシステムキャッシュの場合、ファイルの更新日時)に対し、それぞれマニフェストに保存されてるrevalidate情報と比較してキャッシュを破棄すべきかどうか検証し、破棄すべきと判断されるとdata = undefined
としています。
Data CacheもFull Route Cacheどちらの場合も、data
がundefined
だとキャッシュがなかったものとして後続処理に進みます。
revalidate
とRouter Cacheのクリア
revalidatePath
/revalidateTags
をServer Actionsで呼びだすと、サーバーからはページのRSC payloadが返され、それを契機にRouter Cacheもパージされます。執筆時点では全てのRouter Cacheがパージされるようになっています。
Server ActionsがページのRSC payloadを返すかは以下pathWasRevalidated
に基づいて判定されています。
RSC payloadはFlight protocolと呼ばれるフォーマットのため、内部的にはFlightと呼ばれることもあり、skipFlight
となっています。
このpathWasRevalidated
は以下で設定されています。
TODOコメントの通り現状だと無条件にtrue
ですが、パスがマッチした場合のみRSCを返す形が理想的ではあります。
Router Cacheのクリア処理
App Routerのクライアントサイド処理は、useReducer
で記述された巨大なStateとreducerによって構成されています。Server Actionsがクライアントサイドで実行されると、server-action
というアクションが発行されdispatchされます。このserver-action
のreducerでページRSC payloadを受け取ったらRouter Cacheのクリアなどを行うようになっています。
revalidate
(もしくはcookie操作)しなかった場合、flightData
はnull
となります。
後続の上記処理で空のCacheNodeを作成してreducerとして返すことで、Router Cacheをクリアしています。
今回の調査はここまでです。revalidate
実行時のサーバーサイド〜クライアントサイドの大体の処理イメージは掴めたので、筆者としては満足しました。
感想
Next.jsのデバッグや調査も回数を重ねるごとに、以前よりかは小慣れてきたように感じます。この手の大規模な実装の調査スキルは仕事に生きてくる部分もあるし、シンプルに楽しいです。こういった調査を経て.next
配下やクライアントサイドでの処理の解像度が上がると、バグの調査などが捗ることもメリットと言えそうです。
Next.jsの実装は大規模で筆者が理解してる範囲などごく一部ではあるので、今後もこういった調査を通してよりNext.jsに対する見識を深めていきたいと思います。
Discussion