キャッシュフロー計算書の教材で、ページの一番下まで読んでから次の章へ切り替えると、URLは新しい章に変わるのに本文が前章のスクロール位置を引きずったままだった。「間接法の仕組み」を開いたはずなのに、画面は前章の末尾を映している。scrollTop は 5440 のまま、ぴくりとも動かない。画面を見て違和感を拾い、原因を追わせた。仮説を立てては外し、HMRを疑い、ログを仕込み、最後にたどり着いたのは「ref が画面に映っていない方のスクローラーを掴んでいた」という真因だった。
違和感を拾ったところ
boki3 の章ページ群は、URLの ?ci= で章を切り替える作りになっている。あるとき、本文を最下部までスクロールしてから次の章に進んだら、新しい章のタイトルが出ているのに本文は前章の終わりのまま止まっていた。章を切り替えたら本文は先頭に戻るのが当然なのに、戻らない。
章切り替え時にスクロール位置をリセットする機構(watchChapterIdx)は既に入れてあったはずだった。それが効いていない。まず agent-browser でバグを再現し、.scroll-content が本文のスクローラーだと当たりをつけて、リセットが発火しているかを調べさせた。
仮説1:watchの発火方法が不安定?(外れ)
最初に疑ったのは、リセットを発火させる仕組みだった。テンプレート内で {{ void (watchChapterIdx = chapterIdx) }} のように ref へ代入して watch を起動する書き方をしていて、これが不安定なのではと考えた。
そこで、テンプレート代入をやめて route のクエリ変化を直接 watch する方式に書き換えさせた。これで章が変わるたびに確実にハンドラが走るはず——だったが、scrollTop は 5440 のまま動かなかった。
仮説2:HMRが反映されていない?(外れ)
コードは直したのに挙動が変わらない。次に疑ったのはHMRだ。ホットリロードが古いコードを掴んだままなのではと考え、ページを完全リロードした。それでも 5440。
念のため、Viteが実際に配信しているソースを直接確認させた。新しいコードはちゃんとロードされていた。HMRのせいではなかった。
ログを仕込んで「発火はしている」と分かった
ここで手を動かす向きを変えた。watch のハンドラと、リセット処理の中にログを仕込んで、何が起きているかを直接見た。
すると resetCount=1 が出た。watch は発火している。リセット処理も呼ばれている。なのに画面の scrollTop は変わらない。「発火していない」のではなく「発火しているのにリセットが効いていない」——これで問題の場所が一段絞られた。
真因:2つのスクローラーのうち、非表示側を操作していた
決め手は、.scroll-content が画面に1つだとばかり思い込んでいた前提を疑ったことだった。
MillerViewer は、デスクトップ版とモバイル版の両方のスロットを同時にレンダリングしていた。CSSで片方を隠しているだけで、DOM上には .scroll-content が2つ存在する。そして ref="contentRef" が両方の .scroll-content に重複バインドされ、contentRef は後から登録された非表示のモバイル側を指していた。
つまり、リセット処理は毎回ちゃんと走っていた。ただし掴んでいたのは画面に映っていない方のスクローラーで、そこを scrollTop=0 に戻していた。表示中のデスクトップ側の 5440 には誰も触れていない。だから画面は前章の末尾のまま動かなかった。resetCount=1 が出ていたのに何も変わらなかったのは、これが理由だった。
修正:表示中のスクローラーを取りに行く
ref="contentRef" のバインドを外し、getScrollEl というヘルパーを足した。これは複数ある .scroll-content の中から「いま表示されている方」(offsetParent が存在する=CSSで隠れていない要素)を取得して返す。リセットも、右TOCクリックでのスクロールも、すべてこのヘルパー経由で表示中の要素を対象にする。
// 複数ある .scroll-content から「画面に映っている方」を返す
const getScrollEl = () =>
[...root.querySelectorAll('.scroll-content')]
.find((el) => el.offsetParent !== null) ?? null
副次効果もあった。右TOCをクリックして見出しへ飛ぶ scrollToHeading も、それまでは非表示側を操作していて効かない場面があった。表示中要素を掴むようにしたことで、こちらの副次バグも一緒に消えた。
同じ構造は ch1〜ch5 にも入っていた。byte単位で同一のコードがコピーされていて、全部が同じバグを抱えていた。ch0〜ch5 の6ファイルに同じ修正を一括で当てさせた。
検証中に mounted hook で Vue の warn が出た瞬間があったが、これはHMR過渡状態の古いバッファだった。agent-browserのセッションを閉じて新規で開き直したらエラーは出なかった。
レビューとコミット
未コミットの変更を Codex(gpt-5.5)でレビューした。致命的な指摘はなし。SSRガード、requestAnimationFrame でDOM確定後に要素を取り直す処理、onUnmounted での observer 破棄も妥当という評価だった。そのまま main に直接コミットした(6ファイル / +186 -85)。
学びメモ
- 「発火していない」と「発火しているが効かない」は別の病。 ログで
resetCount=1が出た瞬間に、watchを疑うのをやめてリセット対象を疑う方へ切り替えられた。発火の有無を先に確定させると、無駄な仮説を一つ減らせる refは同名要素が複数あると後勝ちで上書きされる。 デスクトップ/モバイルの出し分けをCSSの表示切替でやっていると、DOMには両方残る。1つのつもりでrefを張ると、知らないうちに非表示側を掴む。「いま表示されている要素」をoffsetParentで取りに行く方が事故らない- 同じコードがコピーされていたら、バグもコピーされている。 ch0で見つけた1件は、ch1〜ch5にもそのまま6件あった。直すときは横並びのファイルを全部見る
- 画面の違和感は人間が拾い、再現・仮説検証・修正・横展開はAIに回す。今日も「scrollTopが5440のまま」という小さな違和感が入口だった