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 18.3.1 / 19.3.1(Next.js を使わない最小構成で、通知の有無の確認に使用)
  • Playwright 1.64.1(playwright-core)
    • ブラウザは chrome-headless-shell 148 を --single-process で起動
  • 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 つ並びます。

  1. サーバーから中身と $RC が届き、境界が表示待ち(<!--$~-->)になる
  2. Provider の値が変わり、React が境界をクライアントで描画し直す。境界の中にある <template id="B:0"> は削除されるが、境界の外(<body> の直下)にある <div hidden id="S:0"> は残る
  3. $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 の値が変わっていないかを疑ってみてください。