• #SSR
  • #hydration
  • #Vue
  • #pure-function
  • #refactoring
  • #unit-test
開発eurekapu-nuxt4メモ

MillerViewer SSRハイドレーションバグ修正と純粋関数リファクタリング

何が起きていたか

MillerViewer.vueの3列目で、スライドが二重にハイライトされる現象が出ていた。1列目・2列目は正常なのに、3列目だけ selected クラスが2箇所に付く。目を疑ったが、リロードするたびに再現する。

原因はSSR時とクライアント側で選択インデックスがズレていたこと。SSRでは globalIdx = 0 を初期値として使い、クライアント側ではlocalStorageから復元した値を使う。この不一致がハイドレーション時に衝突して、SSR側の selected クラスとクライアント側の selected クラスが両方DOMに残っていた。

調査の流れ

Chrome DevToolsで根本原因を特定

  1. Chrome DevToolsでページを開き、SSRが返したHTMLを確認
  2. 3列目のスライド要素を検索し、selected クラスが付いている要素を洗い出した
  3. SSR HTML上では globalIdx=0 に対応するスライドに selected が付く
  4. クライアント側のlocalStorageには前回の閲覧位置(例: globalIdx=5)が保存されている
  5. ハイドレーション後、Vueが globalIdx=5 で再計算するが、SSR時の selected クラスがDOM上に残り続ける

根っこは「SSRとCSRで初期状態が異なる」という典型パターンだった。

修正内容

SSRハイドレーションバグの修正

onMounted 内でlocalStorageから値を復元するタイミングを調整し、SSR時には一切localStorageにアクセスしない設計に変えた。

// SSR時は固定値、CSR時にlocalStorageから復元
const globalIdx = ref(0)

onMounted(() => {
  globalIdx.value = resolveStoredIdx(localStorage, slideCount)
})

ポイントは resolveStoredIdx を純粋関数として切り出したこと。storageオブジェクトを引数で受け取るため、テストではモックを渡せる。

純粋関数の抽出リファクタリング

MillerViewer.vue内に埋まっていた計算ロジックをユーティリティファイルに切り出した。

関数名責務
resolveStoredIdxlocalStorage値の読み取り・バリデーション・デフォルト値返却
calcProgressスライド総数と現在位置からプログレス率を計算
countSlidesネストされたコンテンツ構造からスライド総数をカウント

いずれも外部状態を読まない純粋関数で、同じ引数なら常に同じ値を返す。コンポーネントのロジックが引数と戻り値だけで説明できるようになり、テストも書きやすくなった。

ユニットテスト28件追加

切り出した3関数に対して網羅的にテストを書いた。

  • resolveStoredIdx: localStorage未設定、不正値、範囲外、正常値の各ケース
  • calcProgress: 0件、1件、途中、最終スライドの各ケース
  • countSlides: 空配列、ネスト構造、フラット構造の各ケース

既存の243件のテストもすべて通過。壊していないことを確認してからマージした。

# テスト結果
Test Files 7 passed
Tests 271 passed (243 existing + 28 new)

コーディングルールの追加

この作業を通じて、~/.claude/CLAUDE.md に「副作用のない関数を作る・副作用を隔離する」ルールを追加した。

判断基準を3つの質問にまとめた:

  1. 同じ引数で常に同じ結果を返すか
  2. 関数の外の状態を読んでいないか(ref、store、DOM、Date.now等)
  3. 関数の外の状態を変えていないか(代入、API呼び出し、DOM操作等)

3つともYesなら純粋。1つでもNoなら副作用を分離できないか検討する。

振り返り

SSRハイドレーションバグは「目で見ても原因が分からない」タイプのバグだが、Chrome DevToolsでSSRの生HTMLを読んだ瞬間に selected クラスが2箇所に付いているのが見えて、そこから芋づる式に原因を掘り当てた。

純粋関数への切り出しは、バグ修正のついでにやった。結果として、コンポーネントから計算ロジックが消え、テスト28件が10秒以内に回るようになった。「副作用を隔離する」というルールを書いたが、やってみると「テストが書ける = 設計が正しい」という感触が手に残った。