Next.js App Router 知られざるClient-side Cacheの仕様
前回、App Routerの遷移の仕組みと実装についてまとめました。
今回はこれの続編として、App RouterのClient-side Cacheの仕様や実装についてまとめようと思います。まだドキュメントに記載のない仕様についても言及しているので、参考になる部分があれば幸いです。
App Routerのcache分類
App Routerは積極的にcacheを取り入れており、cacheは用途や段階に応じていくつかに分類することができます。まずはそのcacheの分類を確認してみましょう。
Request Deduping
Request Dedupingはレンダリングツリー内で同一データのGETリクエストを行う際に、自動でまとめてくれる機能です。
nextjs.org/docsより
デフォルトでサポートしているのはfetchのみですが、Reactが提供するcache
を利用することでDBアクセスやGraphQLでも同様のcacheを実現することができます。
これはNext.jsではなくReact側でfetchを拡張[1]することで行なっているようです。
以下の記事がRequest Dedupingについてより詳しく解説されているので、興味のある方はぜひご一読ください。
Caching Data
Caching DataはRequest Deduping同様、主にfetchによるGETリクエストが対象ですが、Request Dedupingは同一レンダリングツリー内で有効になるcacheなのに対し、Caching DataはCDN上などのlocation単位で有効なcacheです。Request Dedupingは1リクエストに対し、Caching Dataは複数リクエスト・複数ユーザーに対し有効と考えるとわかりやすいかと思います。
nextjs.org/docsより
これはReact側ではなく、Next.js側でfetchを拡張[2]することで実現しているようです。
Caching Dataはrevalidate
オプションやfetchCache
を指定することで有効になります。
こちらも先述の記事を書かれたmugiさんがシリーズ的に解説されているので、興味のある方はぜひご一読ください。
CDN cache
App Routerではfetch
のcacheの話に目が行きがちですが、従来通り静的ファイルもCDN cacheとして扱えるよう設計されています。
静的ファイル置き場であるpublic
フォルダはCDN cache可能なファイルとなることが想定され、VercelにデプロイするとデフォルトでCDN cacheされます。また、Static Site Generation (SSG)した結果も同様に静的ファイルになるので、これらもCDNにcacheすることが可能です。
Client-side caching
上記3つのcacheはユーザーからするとサーバー(CDN)側のcacheです。対して、Client-side cachingは、文字通りクライアントサイドのインメモリなcacheです。Client-side cachingは、レンダリングしたReact Server Componentsをcacheします。インメモリなのでリロードやMPA遷移を挟むと消えてしまいますが、App Router間の遷移においては有効です。
このcacheについてはNext.jsのドキュメントではあまり詳細に語られておらず、筆者が確認した限りだと以下くらいの情報しかありません。
The new router has an in-memory client-side Cache that stores the rendered result of Server Components (payload). The cache is split by route segments which allows invalidation at any level and ensures consistency across concurrent renders.
As users navigate around the app, the router will store the payload of previously fetched segments and prefetched segments in the cache.
This means, for certain cases, the router can re-use the cache instead of making a new request to the server. This improves performance by avoiding re-fetching data and re-rendering components unnecessarily.
<以下Deepl訳>
新しいルーターには、Server Componentsのレンダリング結果(ペイロード)を保存するインメモリ・クライアントサイド・キャッシュがあります。キャッシュはルートセグメントで分割されているため、どのレベルでも無効化でき、同時レンダリング時の一貫性を確保できます。ユーザーがアプリ内を移動すると、ルーターは以前にフェッチしたセグメントとプリフェッチしたセグメントのペイロードをキャッシュに保存します。
これは、特定のケースにおいて、ルーターがサーバーに新たなリクエストを行う代わりにキャッシュを再利用できることを意味します。これにより、不必要なデータの再取得やコンポーネントの再レンダリングを回避し、パフォーマンスを向上させることができます。
上記内容は「キャッシュの無効化」の説明や「特定のケース」の定義など、いくつかの点で説明が省略されています。以降はこのClient-side Cacheについて、実装を追いながらより詳細な仕様について確認していきたいと思います。
Client-side Cacheの保存と利用
Client-side CacheはApp Router的にどう実装されているのでしょう?Client-side Cacheは内部的にはprefetchCache
と呼ばれています。文字通りprefetch時に格納されるのですが、実はprefetch以外でも格納されます。なぜprefetchCache
という命名なのかは不明ですが、開発中に仕様が変わり続けて残ってしまったのかもしれません。詳細は後述しますので、まずはprefetchCache
の実装を確認してみます。
前回の記事でも説明したように、App Routerは内部的にuseReducer
ベースで作成したStateで多くの状態を管理しており、prefetchCache
もそのStateの一部として管理されています。具体的にはprefetchCache: Map<string, PrefetchCacheEntry>
のdata
にprefetchのPromiseごと格納しています。
このcacheは遷移時に発火するnavigate
アクションのreducer内で読み取られます。prefetchCache
という命名が紛らわしいのですが、cacheがなかった場合、fetchを行ってこのcacheを作成するなど、App Routerの遷移には必ずprefetchCache
が必要になります。
ちなみにPromiseから値を同期的に読み取るreadRecordValue
は、Promiseを拡張して行なっているようです。(これも少々行儀が悪い気がしますが、、、)
追記: 指摘いただいたところによると、これはどうやらReactのPromise 1st Class Supportの仕様が由来した実装のようです。
Client-side Cacheの種別
さて、Client-side Cacheには内部的にauto
/full
/temporary
の3種類が存在します。
コメントに説明があるのでDeepl翻訳してみます。
auto
- ページが動的な場合は、ページデータを部分的にプリフェッチし、静的な場合はページデータを完全にプリフェッチします。full
- ページデータを完全にプリフェッチする。temporary
- これはnext/link
でprefetch={false}
が使われているときや、プログラムでルートをプッシュするときに使用されます。
ここで言う動的なページとはDynamic Renderingと呼ばれる、リクエストが来るまでレンダリングできないページを指します。App RouterはDynamic Renderingなページかどうかをno-store
なfetchがあるかどうかや、Dynamic Functions(cookies・headersなど)が使われているかどうかで判断します。App Routerではnext build
時に一度各pageコンポーネントを実行してるので、その時に判断されるものと考えられます。
これらのcache種別は基本的にLink
コンポーネントのprefetch
と対応関係にあります。
-
auto
:prefetch={undefined}
-
full
:prefetch={true}
-
temporary
:prefetch={false}
以下の簡単なデモページで挙動を確認してみましょう。
デモページ
Links
にあるリンクはそれぞれ、cache種別ごとにページリンクになります。auto
のみDynamic Renderingかどうかで分岐があるので、2ページ用意しています。
URL | prefetch | Dynamic Rendering | cache種別 |
---|---|---|---|
/cache_auto/static |
undefined |
× | auto |
/cache_auto/dynamic |
undefined |
○(next/headers を利用) |
auto |
/cache_full |
true |
× | full |
/cache_temporary |
false |
× | temporary |
画面内に要素があるとprefetchが発火する
viewport内にLink
が入ってくるとprefetch
アクションが発火し、リンク先ページのレンダリング結果を取得しようと試みます。prefetch={false}
な/cache_temporary
は当然prefetchされないので他の3ページのprefetchが確認できます。
レンダリング結果がflightで送られてくる
/cache_auto/static
のレスポンスBodyを確認するとFlightが確認できます。Flightについては前回の記事でも言及していますが、React Server Components(RSC)をレンダリングした結果を表現する、独自のデータフォーマットです。
Dynamic Rendering部分がレンダリングされないので、他ページよりレンダリング結果が減っている
/cache_auto/dynamic
ページの実装は以下のようになっています。
// /src/app/cache_auto/dynamic/page.tsx
export default async function Page() {
const res = await fetch("https://dummyjson.com/products");
await timer();
const data = await res.json();
// Dynamic Functions
const headersList = headers();
console.log("headersList", headersList);
return (
...
);
}
page
がDynamic Functions(next/headers
)に依存しているため、このページはDynamic Renderingと判定されます。
以下は実際のレスポンスBodyです。ページの内容的には/cache_auto/static
より多いはずですが、Dynamic Renderingはprefetch時点ではレンダリングされないため、/cache_auto/dynamic
の方がレンダリング結果が少ないことが確認できます。
1:HL["/_next/static/css/2a761abac1cc65a8.css",{"as":"style"}]
0:[[["",{"children":["cache_auto",{"children":["static",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/2a761abac1cc65a8.css","precedence":"next"}]],["$L3",null]]]]
4:I{"id":"663","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
5:I{"id":"6712","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
6:I{"id":"863","chunks":["863:static/chunks/863-a23d5de87448ddb7.js","919:static/chunks/app/cache_auto/dynamic/page-d873c36f84943bee.js"],"name":"","async":false}
2:[["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","div",null,{"className":"flex min-h-screen flex-col items-center justify-between p-24","children":["$","div",null,{"className":"w-full max-w-5xl","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children","static","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":[["$","main",null,{"children":[["$","h1",null,{"className":"mb-4 text-3xl font-extrabold text-gray-900 dark:text-white md:text-5xl lg:text-6xl","children":[[["$","span",null,{"className":"text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400","children":"`app`"}],"Â "],"cache auto static"]}],["$","p",null,{"className":"text-lg font-normal text-gray-500 lg:text-xl dark:text-gray-400","children":"This is an example page."}],["$","div",null,{"className":"mt-10","children":[["$","h2",null,{"className":"mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-4xl lg:text-4xl","children":"Links"}],["$","ul",null,{"className":"list-disc pl-5","children":[["$","li",null,{"children":["$","$L6",null,{"href":"/cache_auto/static","className":"underline","children":"/cache_auto/static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_auto/dynamic","className":"underline","children":"/cache_auto/dynamic"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_full","className":"underline","prefetch":true,"children":"/cache_full"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_temporary","className":"underline","prefetch":false,"children":"/cache_temporary"}]}]]}]]}]]}],null],"segment":"__PAGE__"},"styles":[]}],"segment":"static"},"styles":[]}],"segment":"cache_auto"},"styles":[]}]}]}]}]}],null]
3:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]
/cache_auto/static
のFlight
0:[["children","cache_auto",["cache_auto",{"children":["dynamic",{"children":["__PAGE__",{}]}]}],"$L1",[[],["$L2",null]]]]
3:I{"id":"663","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
4:I{"id":"6712","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
1:["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L4",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children","dynamic","children"],"loading":"loading...","loadingStyles":[],"hasLoading":true,"error":"$undefined","errorStyles":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":null,"segment":"__PAGE__"}}],"segment":"dynamic"},"styles":[]}]
2:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]
/cache_auto/dynamic
のFlight
/cache_full
のFlight
/cache_full
は/cache_auto/static
と特段変わった様子はないようです。full
の大きな違いはClient-side Cacheの有効時間にあるためです。
Client-side Cacheの有効期限
cacheというからには当然ながら有効期限があり、前述のcacheの種別によって有効期限の仕様が異なります。内部的にはこれはステータスとして管理されており、以下のenumで定義されています。
上記よりcacheのステータスは、以下のように分類されることがわかります。
-
fresh
: 新しいcache -
reusable
: 再利用可能なcache -
stale
: 古いcache -
expired
: 破棄されるべきcache
このステータスは直後に定義されている関数によって内部的に判定されます。
内部的にはprefetchTime
となっていますが、prefetch={false}
(temporary
)の時はprefetchは発行されないので、実際にはfetchからの経過時間となります。上記関数より、cacheは種別・取得からの経過時間・lastUsed
(cacheの最後の利用時間)によって以下のようなステータス分類が行われていることがわかります。
時間判定 | auto |
full |
temporary |
---|---|---|---|
prefetch/fetchから30秒以内 | fresh |
fresh |
fresh |
lastUsedから30秒以内 | reusable |
reusable |
reusable |
prefetch/fetchから30秒~5分 | stale |
reusable |
expired |
prefetch/fetchから5分以降 | expired |
expired |
expired |
cacheのステータスによってprefetch/fetchの再取得には差異があり、実際に確認してみると以下のような差異がありました。
-
fresh
,reusable
: prefetch/fetchを再発行せず、cacheを再利用する -
stale
: Dynamic Rendering部分だけ遷移時に再fetchを行う -
expired
: prefetchh/fetchを再発行する
Client-side Cacheのrevalidate
しかし、cacheというからには当然任意のタイミングでrevalidateしたいケースが存在するであろうことが想像できます。
筆者が確認した限り、ドキュメントには有効期限の話やrevalidateする方法についての記載は見つけられませんでした。Dynamic Renderingなページでも、fresh
やreusable
になりうるため、cacheがあれば再利用されてしまいます。
On-Demand Revalidationの機能なども試してみましたが、これがクリアできるcacheはやはりサーバー側(Caching Data)が対象のようで、Client-side Cacheをクリアすることはできませんでした。
これについてはすでにいくつかissueが立っており、以下のissueが最も盛んに議論されているようでした。
上記issueではrouter.refresh()
を利用する手段などが提案されており、試したところ、確かにcacheを利用せず再度fetchした内容が反映されている様子が確認できました。ただ、これはスマートな解決策とは言い難いところです。できることならClient-side Cacheの時間を任意に指定できたり、Dynamic Renderingの場合はデフォルトでrouter.refresh()
を呼び出してくれたり、任意のタイミングでrevalidateする手段が提供されていることが望ましい気がします。また、おそらくですがrouter.refresh()
ではページ全体をrefreshしてしまうので、ドキュメントにあった以下のような部分的なcacheの無効化ができるのはNext.js側だけで、開発者側ではできないのではないかと考えられます。
キャッシュはルートセグメントで分割されているため、どのレベルでも無効化でき
せめて無効化できない時間については仕様の記載が欲しいので、issueを立てました。
今後何かしらの対応がされ、ドキュメントも更新されることを願います。
感想
今回はClient-side Cacheに重きを置いて実装や仕様を調査してみました。App Routerの積極的なキャッシュはINP(Interaction To Next Paint)などの改善が見込まれるし、個人的には歓迎してる部分も多いです。一方でClient-side Cacheが一定時間保持され続けてしまうことについては、ドキュメントでの説明や対応方法の不足があり、プロダクションで利用するにはかなり大きな制約になると感じています。特にユーザー情報を扱うWebアプリ開発などでは、この手の問題は致命的になり得ます。
App Routerは注目度も高く、すでにstable(安定化)が宣言されたわけですが、利用する開発者側からすると挙動や機能もまだ「安定」していない部分があると筆者は考えています。App Routerはユーザーにとっても開発者にとっても多くのメリットがあるし、非常に魅力的な機能を兼ねているので、上記問題含めさらに多くの改善や発展に期待したいところです。
Discussion