React Hydration Failed がブラウザ拡張 (extension) による script/tag の挿入で起きるケース

ブラウザ上で React アプリを SSR(サーバーサイドレンダリング)した際、コンソールに次のようなエラーが表示されることがあります。

Warning: Text content does not match server-rendered HTML.
Hydration failed because the initial UI does not match what was rendered on the server.

通常はコンポーネントの条件分岐や日付のズレなどが原因ですが、意外な落とし穴としてブラウザ拡張機能による DOM の改変が原因となるケースがあります。たとえば、ColorZilla や広告ブロッカー、ダークモード拡張などが <head><body>scriptstyle タグを動的に追加すると、SSR で出力された HTML とクライアント側での初期 HTML が異なり、React の Hydration が失敗してしまうのです。

本記事では、ブラウザ拡張が原因の Hydration Failed に遭遇した際の原因特定方法、再現手順、そして実用的な回避策について解説します。

1. Hydration Failed の仕組みとブラウザ拡張の影響

React 18 では、サーバーから送られた HTML をもとにクライアントが仮想 DOM を構築し、差分がないかを検証した上でイベントをバインドする「hydration」を行います。このとき、サーバー側の HTML とクライアント初期 DOM が 1 文字でも異なると警告が発生します。

ブラウザ拡張は以下のような変更を行うことがあります。

  • <head> 内に <script><style> を追加
  • <body> に tracking pixel 用のタグを挿入
  • 属性(data-*style)を動的に付与
  • meta タグの追加や順序変更

これらの変更は、React 側からは「クライアント初期 HTML がサーバーと異なる」と認識されるため、Hydration Failed の警告が発生します。

2. 実際の再現例とデバッグ方法

たとえば以下の SSR 出力を考えます。

<head>
  <title>My SSR App</title>
</head>
<body>
  <div id="root"><span>Hello</span></div>
</body>

拡張機能が <head> にスクリプトを追加した場合、クライアント初期 DOM はこうなります。

<head>
  <title>My SSR App</title>
  <script src="chrome-extension://abcd1234/injected.js"></script>
</head>

このわずかな差分が原因で、React は「サーバーとクライアントで DOM が違う」と判定し、次のエラーを出します。

Warning: Expected server HTML to contain a matching <script> in <head>.

デバッグのコツ

  • 拡張機能を無効化して再読み込み → 警告が消えるか確認
  • document.head.innerHTML を console.log で確認 → SSR 出力との差分を比較
  • ブラウザの DevTools → Elements タブ → head/body の変更履歴 を観察

こうすることで、ブラウザ拡張が追加したタグや属性を特定できます。

3. 回避策と suppressHydrationWarning の使いどころ

根本的な解決としては「拡張機能を無効化する」ですが、すべてのユーザーにそれを求めるのは現実的ではありません。以下の対策が有効です。

  1. 不変な SSR 出力を保証する
    • ランダムな ID や日時を直接 DOM に出力しない
    • サーバーとクライアントで生成結果が一致するようにする
  2. クライアント専用レンダーを使う
    • 問題のある要素に suppressHydrationWarning を付与してクライアントで再描画させる
    • 例:
<div suppressHydrationWarning>
  {typeof window !== 'undefined' && <script src="/dynamic.js"></script>}
</div>
  1. クライアントマウント後に追加する
    • useEffect を利用して、マウント後に動的に挿入することで SSR との差分をなくす
useEffect(() => {
  const s = document.createElement('script')
  s.src = '/dynamic.js'
  document.head.appendChild(s)
}, [])

この方法ならサーバーとクライアントの初期 HTML が一致し、Hydration Failed を回避できます。

4. suppressHydrationWarning を乱用しない注意点

suppressHydrationWarning は便利ですが、すべての要素に付けると本来検出すべき不一致まで無視してしまいます。適用範囲は最小限に絞り、特に外部拡張やブラウザ環境依存の要素に限定することが望ましいです。

また、React 18 以降では コンソール警告が出てもアプリがクラッシュしない 仕様になっていますが、SEO やパフォーマンスの観点からも hydration の不一致は放置すべきではありません。

5. チーム開発・CI/CD での検出方法

CI/CD 環境では拡張機能の影響は基本ありませんが、開発者個人の環境では再現しやすいため、Playwright や Cypress などの E2E テストで head/body の snapshot を取得し、差分がないか確認する と安心です。

さらに、リリース前に複数ブラウザで動作確認し、ブラウザ拡張を入れた状態でも最低限の機能が動作するかをチェックすることをおすすめします。

6. まとめ

React の Hydration Failed は単なる条件分岐ミスだけでなく、ブラウザ拡張による DOM 改変でも発生する 厄介な問題です。原因特定には拡張機能の無効化や DOM ログ出力が有効で、回避策としては以下を押さえましょう。

  • サーバーとクライアントで同一の HTML を出力する
  • 拡張機能が変更しそうな部分はクライアント専用レンダーにする
  • 必要な箇所にのみ suppressHydrationWarning を適用

これらを実践することで、ブラウザ拡張が導入された環境でも安定して SSR + Hydration を行えるようになります。

参考

タイトルとURLをコピーしました