Argano の松田です。
Next.js の App Router で開発しているアプリで、E2E テストがときどき失敗していました。調べると、ページを読み込んだ直後の短い時間だけ、DOM 上に同じ要素が 2 つ存在していました。画面に見えているのは片方だけで、もう片方は hidden 属性が付いた要素の中にあります。
社内では「ハイドレーションエラーではないか」という意見も出たので、React のコードを読み、最小構成で再現して確かめました。
先に結論を書いておきます。
- Suspense の中身が表示される前に、その上にある Provider の値が変わると、React はその境界の hydration をやめてクライアントで描画し直す。境界の中で Context を読んでいなくても起きる
- これはハイドレーションエラーではなく、hydration できないときのフォールバックである
- React 19.2 以降は、中身の表示待ちの間に描画し直すと同じ要素が一時的に 2 つ並び、E2E テストを失敗させることがある
検証環境
- Next.js 16.4.3(App Router)
- 同梱されている React は
19.3.0-canary-278794d7-20261002
- 同梱されている React は
- React 18.3.1 / 19.3.1(Next.js を使わない最小構成で、通知の有無の確認に使用)
- Playwright 1.64.1(playwright-core)
- ブラウザは chrome-headless-shell 148 を
--single-processで起動
- ブラウザは chrome-headless-shell 148 を
- Node.js 24.18.1
Next.js は next build と next start で起動し、ローカルで計測しました。時間や回数の計測結果は、この環境でのものです。React のコードは react/react(旧 facebook/react)の main ブランチ(コミット e8738f0)と、npm で配布されているビルドを読んでいます。記事中の React のコードは、要点だけを抜き出したり整形したりしています。
起きていた現象
E2E テストは、strict mode violation というエラーでときどき失敗していました。後述の再現コードで同じ状況を作ると、Playwright は次のエラーを出します。
locator.click: Error: strict mode violation: getByText('こんにちは') resolved to 2 elements:
Playwright のロケーターは、複数の要素にマッチすると、操作がエラーになります(Strictness)。失敗したタイミングの DOM は次のようになっていました。Next.js が出力する要素とスクリプトは省略しています。
<body>
<header>ログイン状態: <!-- -->guest</header>
<main>
<section><p>こんにちは</p><button>いいね 0</button></section>
</main>
<div hidden id="S:0">
<section><p>こんにちは</p><button>いいね <!-- -->0</button></section>
</div>
</body>
画面に表示されるのは <main> の中の要素だけです。<div hidden id="S:0"> の中の要素は表示されませんが、DOM には存在するので getByText にマッチします。<div hidden id="S:0"> はしばらくすると DOM から消えます。後述の再現コードで 10 回計測したところ、要素が 2 つ並んでいた時間は 127〜148ms でした。
再現コード
次のコードで再現できます。まず、ルートのレイアウトで LoginStatusProvider を使います。
// app/layout.tsx
import type { ReactNode } from "react";
import { LoginStatusProvider } from "./LoginStatusProvider";
import { Header } from "./Header";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ja">
<body>
<LoginStatusProvider>
<Header />
{children}
</LoginStatusProvider>
</body>
</html>
);
}
LoginStatusProvider は、マウントされた後にログイン状態を返す API を呼び、結果を Context に入れます。ここでは API の代わりに、150ms 後に結果を返す関数を使っています。
// app/LoginStatusProvider.tsx
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type LoginStatus = "unknown" | "guest" | "member";
// ログイン状態を返す API の代わり。150ms 後に結果が返る
const fetchLoginStatus = () => new Promise<LoginStatus>(resolve => setTimeout(() => resolve("guest"), 150));
const LoginStatusContext = createContext<LoginStatus | null>(null);
export function LoginStatusProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<LoginStatus>("unknown");
useEffect(() => {
fetchLoginStatus().then(setStatus);
}, []);
return <LoginStatusContext value={status}>{children}</LoginStatusContext>;
}
export function useLoginStatus() {
const status = useContext(LoginStatusContext);
if (status === null) throw new Error("useLoginStatus must be used within LoginStatusProvider");
return status;
}
<LoginStatusContext value={status}> は、React 19 から使える Provider の書き方です(createContext)。
// app/Header.tsx
"use client";
import { useLoginStatus } from "./LoginStatusProvider";
export function Header() {
const status = useLoginStatus();
return <header>ログイン状態: {status}</header>;
}
ページは Server Component です。<Suspense> の中に、データの取得を待つコンポーネントを置きます。
// app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { Greeting } from "./Greeting";
async function SlowSection() {
// ビルド時に静的生成させず、リクエストのたびに描画する
await connection();
// データ取得の代わりに 100ms 待つ
await new Promise(resolve => setTimeout(resolve, 100));
return <Greeting />;
}
export default function Page() {
return (
<main>
<Suspense fallback={<p>読み込み中...</p>}>
<SlowSection />
</Suspense>
</main>
);
}
connection() は、描画をリクエストが来るまで待たせる関数です(connection)。これがないと、ページはビルド時に静的な HTML として生成され、ストリーミングが起きません。
// app/Greeting.tsx
"use client";
import { useState } from "react";
// LoginStatusContext は読んでいない
export function Greeting() {
const [count, setCount] = useState(0);
return (
<section>
<p>こんにちは</p>
<button onClick={() => setCount(c => c + 1)}>いいね {count}</button>
</section>
);
}
このページを Playwright で 20 回読み込んだ結果は次のとおりです。
| 確認したこと | 結果 |
|---|---|
Greeting の部分がクライアントで描画し直された | 20/20 |
DOM 上に <p>こんにちは</p> が 2 つ存在した | 20/20 |
| 次のテストが成功した | 0/20 |
await page.goto(url);
// ヘッダーのログイン状態が guest に変わるのを待ってから操作する
await page.getByText("ログイン状態: guest").waitFor();
await page.getByText("こんにちは").click();
失敗したときのエラーは、20 回とも前述の strict mode violation でした。一方、page.goto() の直後にすぐ click() した場合は 20 回すべて成功しており、要素を探すタイミングによって結果が変わります。
クライアントで描画し直されたかどうかは、サーバーから届いた要素を MutationObserver で記録しておき、最終的に表示されている要素と同じかどうかで判定しました。
なぜ起きるのか
ストリーミング SSR と Suspense 境界
App Router は、<Suspense> の中身の準備を待たずに HTML を送り始めます(ストリーミング SSR)。最初の HTML では、境界の位置にフォールバックが入っています。
<main><!--$?--><template id="B:0"></template><p>読み込み中...</p><!--/$--></main>
中身の準備ができると、同じレスポンスの続きとして、<div hidden id="S:0"> に入った中身と $RC というスクリプトが届きます。React 18.3.1〜19.1.1 の $RC は、中身をすぐに <template id="B:0"> の位置へ移し、フォールバックと置き換えます。
React 19.2 からは、届いた中身はすぐには表示されず、短い時間まとめてから表示されるようになりました(React 19.2 のリリースブログ)。表示待ちの間も、中身は <div hidden id="S:0"> ごと DOM 上に残っています。表示するときは $RV というスクリプトが <div hidden id="S:0"> を削除します。<template id="B:0"> が残っていれば、中身はその位置へ移されます。
境界の状態は、次のコメントで表されます。
| コメント | 境界の状態 |
|---|---|
<!--$?--> | 中身がまだ届いていない |
<!--$~--> | 中身は届いているが、表示を待っている(React 19.2 以降) |
<!--$--> | 中身が表示されている |
中身が表示されても境界はなくならず、コメントも DOM に残ります。表示されることと hydration されることは別で、hydration はこの後に行われます。
クライアントでは、ルートの hydration のときに Suspense 境界の中身は後回しにされます。中身が表示されている境界(<!--$-->)は少し後に低い優先度で、表示されていない境界(<!--$?--> と <!--$~-->)は表示された後で hydration されます。この記事では、まだ hydration されていない境界を「未 hydrate の境界」と書きます。Provider の useEffect はルートの hydration がコミットされた後に動きます。そのため Provider の値は、未 hydrate の境界が残っている間に変わることがあります。
Context の変更は未 hydrate の境界にも届く
Provider の値が変わると、React は下のツリーをたどり、その Context を読んでいるコンポーネントに更新の印を付けます。ところが、未 hydrate の境界の中身はまだ React のツリー(Fiber)になっておらず、DehydratedFragment という 1 つの Fiber があるだけなので、Context を読むコンポーネントがあるかどうかが React には分かりません。そのため、境界ごと「更新あり」として扱います(ReactFiberNewContext.js の propagateContextChanges)。
} else if (fiber.tag === DehydratedFragment) {
// If a dehydrated suspense boundary is in this subtree, we don't know
// if it will have any context consumers in it. The best we can do is
// mark it as having updates.
const parentSuspense = fiber.return;
// ...
scheduleContextWorkOnParentPath(parentSuspense, renderLanes, workInProgress);
再現コードの Greeting が Context を読んでいないのに影響を受けたのは、このためです。
更新が届いた境界はクライアントで描画し直される
更新の印が付いた境界は、ReactFiberBeginWork.js の updateDehydratedSuspenseComponent で処理されます。React はまず、更新を反映する前に、より高い優先度でその境界を hydration しようとします。中身が表示されている境界(<!--$-->)は、通常はここで hydration が終わり、その後で更新が反映されます。
中身がまだ表示されていない境界は hydration できないので、React はサーバーから届いた DOM を削除し、クライアントで描画し直します(retrySuspenseComponentWithoutHydrating)。表示待ちの境界も、中身が届いていない境界と同じ扱いです。
function isSuspenseInstancePending(instance) {
return (
instance.data === SUSPENSE_PENDING_START_DATA || // "$?"
instance.data === SUSPENSE_QUEUED_START_DATA // "$~"
);
}
hidden の div が残って要素が 2 つ並ぶ
次の順に処理が進むと、同じ要素が 2 つ並びます。
- サーバーから中身と
$RCが届き、境界が表示待ち(<!--$~-->)になる - Provider の値が変わり、React が境界をクライアントで描画し直す。境界の中にある
<template id="B:0">は削除されるが、境界の外(<body>の直下)にある<div hidden id="S:0">は残る $RVが実行され、<div hidden id="S:0">が削除される
2 から 3 までの間が、「起きていた現象」で示した DOM の状態です。<main> 側の いいね 0 に <!-- --> がないのは、クライアントで描画された要素だからです。
どの状態のときに Provider の値が変わるかは、JavaScript の読み込みや HTML が届くタイミングで決まります。150ms の待ち時間をなくし、useEffect の中ですぐ setState する形へ変えて、通信の遅延を変えながら 20 回ずつ試しました(キャッシュは無効)。要素が 2 つ並んだのは、遅延なしで 0/20、50ms で 20/20、100ms で 2/20 です。遅延なしでは中身が届く前に描画し直されていて、100ms の残りの 18/20 は先に表示されてから hydration されていました。再現コードでは、Provider の値が変わるタイミングと、テストが要素を探すタイミングの両方で結果が変わりました。
ハイドレーションエラーではない
ハイドレーションエラーは、サーバーで生成した HTML とクライアントでの描画結果が一致しないときに起きるエラーです。今回は hydration の前に更新が届いたので、React はサーバーの HTML と比べずに、クライアントで描画し直しています。比べていないので、エラーにもなりません。
React 18.3.1 では、この処理が回復可能なエラー(recoverable error)として onRecoverableError に通知されていましたが、React 19 では通知されません。この通知は #25692(2022 年)で削除されました。
エラーではありませんが、描画し直された部分では、サーバーで生成した HTML が使われずに捨てられます。通知もないので、気付きにくい動きです。
対策
Provider の値を変えない
Provider の値を変えなければ、Context の変更が未 hydrate の境界に届くことはありません。Provider には参照が変わらない store を渡し、状態の変化は useSyncExternalStore で購読します。
// app/LoginStatusProvider.tsx
"use client";
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
type LoginStatus = "unknown" | "guest" | "member";
// ログイン状態を返す API の代わり。150ms 後に結果が返る
const fetchLoginStatus = () => new Promise<LoginStatus>(resolve => setTimeout(() => resolve("guest"), 150));
const createLoginStatusStore = () => {
let status: LoginStatus = "unknown";
const listeners = new Set<() => void>();
return {
getStatus: () => status,
setStatus: (next: LoginStatus) => {
status = next;
listeners.forEach(listener => listener());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
};
type LoginStatusStore = ReturnType<typeof createLoginStatusStore>;
// Context で渡すのは store。store は作り直さないので、Provider の値は変わらない
const LoginStatusStoreContext = createContext<LoginStatusStore | null>(null);
export function LoginStatusProvider({ children }: { children: ReactNode }) {
// 初期化関数を渡すので、store は最初の描画で 1 回だけ作られる。
// モジュールのトップレベルで作ると、サーバーではすべてのリクエストで同じ store を共有してしまう
const [store] = useState(createLoginStatusStore);
useEffect(() => {
fetchLoginStatus().then(store.setStatus);
}, [store]);
return <LoginStatusStoreContext value={store}>{children}</LoginStatusStoreContext>;
}
export function useLoginStatus() {
const store = useContext(LoginStatusStoreContext);
if (!store) throw new Error("useLoginStatus must be used within LoginStatusProvider");
return useSyncExternalStore(store.subscribe, store.getStatus, () => "unknown" as const);
}
useLoginStatus は再現コードと同じく文字列を返すので、Header.tsx は変更不要です。useSyncExternalStore の第 3 引数(getServerSnapshot)は、サーバーでの描画と hydration のときにだけ使われます。サーバーとクライアントで同じ値を返す必要があるので、"unknown" を返しています(useSyncExternalStore)。
再現コードと同じ条件で 20 回読み込むと、すべて hydration され、要素が 2 つ並ぶこともありませんでした。通信の遅延や CPU の速度を変えた 6 つの条件(各 10 回)と、Suspense の中で useLoginStatus を使った場合(遅延なし・50ms・100ms で各 20 回)でも、すべてサーバーの HTML のまま hydration されました。
E2E テストでは見えている要素に絞る
Provider を直せない場合でも、E2E テストは見えている要素だけを対象にすれば安定します。再現コードで、ヘッダーが guest に変わるのを待ってから、各ロケーターで click() を 20 回ずつ試した結果です。
| ロケーター | 成功 |
|---|---|
getByText("こんにちは") | 0/20 |
getByText("こんにちは").filter({ visible: true }) | 20/20 |
getByRole("button", { name: /いいね/ }) | 20/20 |
getByRoleは、デフォルトでは ARIA の定義で非表示とされる要素にマッチしない(オプションのincludeHidden)filter({ visible: true })は Playwright 1.51 で追加された。1.63 からはvisible()でも書ける(Locator)
ただし、これはテストを安定させるための対策です。クライアントで描画し直されること自体は変わりません。
まとめ
strict mode violation で E2E テストがときどき失敗し、hidden の div の中に同じ要素が残っているときは、Suspense の中身が表示される前に Provider の値が変わっていないかを疑ってみてください。
