MillerViewer SSRハイドレーションバグ修正と純粋関数リファクタリング
何が起きていたか
MillerViewer.vueの3列目で、スライドが二重にハイライトされる現象が出ていた。1列目・2列目は正常なのに、3列目だけ selected クラスが2箇所に付く。目を疑ったが、リロードするたびに再現する。
原因はSSR時とクライアント側で選択インデックスがズレていたこと。SSRでは globalIdx = 0 を初期値として使い、クライアント側ではlocalStorageから復元した値を使う。この不一致がハイドレーション時に衝突して、SSR側の selected クラスとクライアント側の selected クラスが両方DOMに残っていた。
調査の流れ
Chrome DevToolsで根本原因を特定
- Chrome DevToolsでページを開き、SSRが返したHTMLを確認
- 3列目のスライド要素を検索し、
selectedクラスが付いている要素を洗い出した - SSR HTML上では
globalIdx=0に対応するスライドにselectedが付く - クライアント側のlocalStorageには前回の閲覧位置(例:
globalIdx=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内に埋まっていた計算ロジックをユーティリティファイルに切り出した。
| 関数名 | 責務 |
|---|---|
resolveStoredIdx | localStorage値の読み取り・バリデーション・デフォルト値返却 |
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つの質問にまとめた:
- 同じ引数で常に同じ結果を返すか
- 関数の外の状態を読んでいないか(ref、store、DOM、Date.now等)
- 関数の外の状態を変えていないか(代入、API呼び出し、DOM操作等)
3つともYesなら純粋。1つでもNoなら副作用を分離できないか検討する。
振り返り
SSRハイドレーションバグは「目で見ても原因が分からない」タイプのバグだが、Chrome DevToolsでSSRの生HTMLを読んだ瞬間に selected クラスが2箇所に付いているのが見えて、そこから芋づる式に原因を掘り当てた。
純粋関数への切り出しは、バグ修正のついでにやった。結果として、コンポーネントから計算ロジックが消え、テスト28件が10秒以内に回るようになった。「副作用を隔離する」というルールを書いたが、やってみると「テストが書ける = 設計が正しい」という感触が手に残った。