ハイドレーションは描き直さない
一言でいうと
ハイドレーションは、サーバーが描いたDOMにイベントハンドラーを取り付ける作業であり、描き直す作業ではありません。そのため、両側が違うものを描いたとき、画面は静かに間違ったまま残ります。
なぜ必要なのか
サーバーが作ったHTMLは、すでにブラウザーの画面にあります。クライアントが同じコンポーネントをもう一度動かしてみる理由は、画面を作るためではなく、どのDOMノードにどのハンドラーを取り付けるかを知るためです。そのため、両者の結果が同じという前提が崩れると、ハンドラーが見当違いのノードに取り付けられます。
Reactのドキュメントはこの点をはっきり述べています。避けられず異なる値(例: タイムスタンプ)にはsuppressHydrationWarningを使えますが、それは1階層だけに効く脱出口であり、Reactは食い違ったテキストを修正してくれません(hydrateRoot)。警告を消したからといって値が合うわけではない、という意味です。
原因はほとんどいつも、次の4つのどれかです。
| 原因 | なぜ食い違うのか |
|---|---|
| 時刻 | サーバーが描いた時刻とブラウザーが描いた時刻が違います |
| 乱数・連番のid | 2か所で別々に作れば、同じになるはずがありません |
| ロケール・タイムゾーン | サーバーはUTC、ブラウザーはユーザーのタイムゾーンです |
| ブラウザーだけが知る値 | localStorage、画面幅、Cookieなどです |
どう動くのか
値を作る場所を1か所にまとめる
時刻も乱数も、サーバーで1回作って状態に入れて渡せば、両側が同じ値を使います。ssr-coreで初期状態をタグに埋め込んだのが、まさにこのためです。ロケールとタイムゾーンも同様です。フォーマットをIntl.DateTimeFormatに任せつつ、タイムゾーンとロケールをオプションで明示すれば、両側の結果が同じになります(Intl.DateTimeFormat)。何も指定しないと実行環境のデフォルトに従いますが、そのデフォルトがサーバーとブラウザーで違うことが、問題のすべてです。
連番カウンターで作るidは特に厄介です。ReactがuseIdを別に用意した理由を、ドキュメントが説明しています。クライアントコンポーネントがハイドレーションされる順序が、サーバーHTMLが出力された順序と同じだと保証できないため、グローバルなカウンターでは合わせられません(useId)。
本当に違わざるをえない値は後回しにする
ブラウザーだけが知る値は、サーバーが知る方法がありません。このとき正しい方法は、サーバーとクライアントの最初のレンダリングを同じにして、そのあとでブラウザーの値に切り替えることです。最初のレンダリングが同じなので不一致はなく、変わるのはハイドレーションが終わったあとです。
不一致は閉じ込める
同じ不一致でも、どこで起きたかによって持つ意味がまったく違います。境界の内側の不一致なら、その境界の配下だけ描き直せば済みますが、境界の外の不一致はページ全体を描き直させます。最初の画面を速く見せるためにSSRを入れたのに、境界の外の不一致が1つあるだけで、その利点がまるごと失われます。
そのため、比較関数を作るときに守るべき規則が2つあります。
- 形が食い違ったら、その場で止まります。タグが違う、または子の数が違うなら、その下はそもそも対応していません。そのまま下りていくと、項目が1つずれた一覧から不一致が何百件も出てきて、本当の原因1つがその山に埋もれます。
- 抑制は1階層だけです。時刻1か所を見逃すために、その下のツリーまで目をつぶると、その中の本当の不一致が永遠に見えなくなります。上で見たReactの規則と同じです。
現場での姿
最も多い報告は、「リロードすると一瞬違う値が見えてから変わる」ではなく、値が単に間違っているというものです。ハイドレーションはテキストを修正してくれないので、サーバーが描いた古い値がそのまま残り、次の状態変更でようやく更新されます。そのためバグ報告が「たまに数字が合いません」という形で届き、再現手順にリロードが抜けています。
2つ目によくあるのは、開発環境でだけ警告が出る場合です。警告を消すためにsuppressHydrationWarningを上のほうのノードに広く付けるチームがありますが、そうすると警告だけが消えて、間違った値はそのまま残ります。しかも1階層だけにしか効かないので、下のほうの不一致は依然として警告を出します。広く付けるほど、シグナルだけが悪くなります。
最後はタイムゾーンです。サーバーがUTCで描いた日付をブラウザーがローカルのタイムゾーンで描くと、真夜中近くの1日がまるごとずれます。QAが昼間にしかやらなければ、絶対に見つかりません。
直す順序も決まっています。まず警告を消さずに原因を切り分けます。時刻なのか、idなのか、ロケールなのか、ブラウザーだけが知る値なのかです。最初の3つは、値を作る場所をサーバー1か所にまとめれば解消できます。最後の1つだけは、最初のレンダリングを同じにしておいて後回しにする処理が必要です。この区別なしに抑制から付けると、直せたはずの3つまで一緒に覆い隠されます。
次のラボですること
サーバーのツリーとクライアントのツリーを受け取り、食い違った場所をパスとともに見つけ出す比較関数を作ります。属性の順序は無視し、形が食い違ったら止まり、抑制は1階層だけ許可し、不一致ごとに最も近い境界を付けます。最後に、その結果から何を描き直す必要があるかを計算します。