Next.js 16キャッシュコンポーネントの課題を示す図。revalidateTagでのキャッシュ更新遅延やE2EテストでのDOM多重ヒット問題の発生と、expire:0でのキャッシュ即時破棄、正確なDOMロケータによる解決策を対比して描く。

はじめに

Next.js 16 の Cache Components を有効にしたアプリで、性質の異なる2つの不具合に同時に遭遇したことがあります。

ひとつは「データを更新したはずなのに、しばらく画面に反映されない」という実害のあるバグ。もうひとつは「E2Eテストのロケータが、同じ data-testid を持つ要素を2つ拾ってしまう」というテスト側の不具合です。

一見まったく別の問題に見えますが、原因をたどっていくとどちらも Cache Components 特有のキャッシュ・DOM保持の挙動に行き着きました。この記事では、その切り分け方と対処法をまとめます。

こんな人におすすめ

  • Next.js 16 の Cache Components(PPR系のキャッシュ機構)を試している方
  • revalidateTag を呼んだのにデータが反映されず悩んでいる方
  • Playwright の getByTestId が「strict mode violation」で複数要素にマッチしてしまう方
  • キャッシュ層の不具合をどこから疑えばいいか、切り分けの型がほしい方

罠1: revalidateTag の ‘max’ は「即時破棄」ではない

最初に踏んだのはキャッシュの反映遅延です。DBには正しく保存されているのに、画面には古い値が表示され続けるという現象でした。

原因は revalidateTag(tag, 'max') の理解違いでした。このプロファイルは「キャッシュを即座に破棄する」設定ではなく、「向こう1年間、意図的に古いキャッシュを返し続けてもよい」という stale-while-revalidate 方式の指定です。Realtime通知などをトリガーに「更新直後に即座に再取得する」UIパターンとは、そもそも相性が良くありませんでした。

即時反映が必要な箇所では、明示的に有効期限をゼロにする必要があります。

// Before: 'max' は stale-while-revalidate。
// Route Handler からの呼び出しでは、直後の再取得が古いキャッシュを掴んでしまう
revalidateTag(`resource-${id}`, "max");

// After: 次のリクエストで確実に最新データを返す
revalidateTag(`resource-${id}`, { expire: 0 });

あわせて気づいたのが、updateTag は Server Action からは使えるものの、Route Handler からは使えないという制約です。Route Handler側でキャッシュを即時無効化したい場合は、この { expire: 0 } が公式に案内されている代替手段になります。

罠2: fetchのブラウザHTTPキャッシュも見落としがちな経路

revalidateTag まわりを直しても、まだ古いデータが返ってくるケースが残りました。今度の犯人は fetch のレスポンスに付与された Cache-Control: s-maxage=... などのブラウザHTTPキャッシュです。

サーバー側の再検証ロジックをすり抜けて、ブラウザが保持しているレスポンスをそのまま使ってしまう経路が別に存在していました。即時反映が必要な fetch には、明示的に no-store を付ける必要があります。

// Realtime通知直後に最新状態を取りに行くfetchは、
// ブラウザのHTTPキャッシュ経由で古いレスポンスを拾わないよう明示的に無効化する
const response = await fetch(`/api/resource/${id}`, { cache: "no-store" });

キャッシュの反映遅延を疑ったときは、サーバー側の revalidate 設定だけでなく、fetch呼び出し側のキャッシュ指定もセットで確認するのがよさそうです。

罠3: Cache Components (Activity) が前ページのDOMを保持し続ける

反映遅延の問題を片付けたあと、今度はE2Eテストで想定外の失敗が出ました。Playwrightの getByTestId が、同じ data-testid を持つ要素を2つ拾ってしまい、strict mode違反になるというものです。

調べてみると、Cache Components(Activity)が有効な場合、Next.jsはクライアント遷移時に前のページのDOMツリーを破棄しません。<Activity mode="hidden">(実質 display: none !important)として保持し続け、戻る操作を高速化する仕組みになっていました。最大数ページ分の状態をbfcacheのように保持するのは意図的な仕様です。

その副作用として、遷移前後の2つのページに同じ data-testid を持つ要素が同時にDOM上へ存在してしまい、テストのロケータが複数要素にマッチしてしまうわけです。

対策: E2Eロケータをコンテナ配下にスコープする

この問題への対策は、アプリ側のコードを変えることではありません。テスト側のロケータを、詳細画面のコンテナ配下に明示的にスコープすることで解決できます。

// Cache Components (Activity) は遷移前のDOMを非表示のまま保持するため、
// 一覧画面と詳細画面に同じtestidを持つ要素が複数存在する。
// 詳細画面のコンテナ配下に明示的にスコープすることで一意に絞り込む
const detail = page.getByTestId("resource-detail");
const likeBtn = detail.getByTestId(`like-button-${id}`);

ページ全体に対して getByTestId を呼ぶのではなく、まず対象のコンテナを取得し、そこから子要素を辿るようにするだけです。公式ドキュメントのテスト向けガイドでも、同様のスコープ方式が案内されています。

つまづきやすいポイント

  • devビルドだけで確認して「開発サーバー固有の現象では」と誤診断しがちです。本番ビルド(build → start)でも同じ挙動が再現するかを確認しておくと安心です
  • キャッシュのプロファイル名('max' など)だけを見て、「即時反映されるはず」と思い込みやすい点に注意が必要です
  • Server ActionとRoute Handlerで、キャッシュ無効化に使えるAPIが異なることを見落としがちです
  • 画面に「見えている」要素と、テストが本当に操作したい要素が、同じtestidで複数DOM上に存在している可能性を疑いにくい点も落とし穴です

まとめ

Cache Componentsは戻る操作の高速化など明確なメリットがある一方で、キャッシュの反映タイミングとDOM保持の挙動という2つの前提が変わる機能でもあります。

「更新したのに反映されない」系のバグは、まずキャッシュ層のプロファイル設定とfetchのキャッシュ指定を疑うのが近道です。

E2Eテストで想定外のstrict mode違反が出た場合は、アプリのバグを疑う前に、Activityによる前ページのDOM保持を思い出すと切り分けが早くなります。