定義
スクロール復元とは、戻る・再読み込み後にリストが見ていた位置を取り戻す UX です。**仮想リスト(window virtualizer)**は DOM に見える行だけを置くため、復元時に推定高さと実測高さの差で文書高さが変わり、scrollY がクランプされることがあります。
単一 scroll authority(単一スクロール権限)とは、bootstrap script・React effect・virtualizer seed のうち1 経路だけが最終 scroll 位置を決めるというルールです。複数箇所が scrollTo や 6 フレーム reassert を同時に行うと、ちらつき・ガタつきが起きます。
なぜ必要か
無限スクロールリストは sessionStorage に anchor id・scrollY・行高さを保存して cold reload に耐えます。しかし (1) throttle save リスナーが毎レンダー再購読されると保存が 0 回、(2) reveal 後に min-height placeholder を早く取り除くと totalSize が減り scrollY が切られ、(3) bootstrap rAF×3 + inner seed + 6 フレーム scrollBy ループが互いに上書きします。
E2E が Chromium ネイティブ scroll restoration だけを検証すると、スナップショット未保存の false-green になります。
動作の仕組み
- 保存: passive scroll listener を1 回だけ付与(
[]deps)。capture 関数は latest-ref で読む。250ms throttle で sessionStoragesetItem。 - bootstrap: inline script の末尾は
window.scrollTo(0, y)1 回(rAF チェーン・loadリスナーは除去)。 - React 復元:
visibility:hiddenを維持 → anchordata-row-idの viewport offset と保存値を比較 →scrollBy(delta)1 回。anchor がなければピクセルscrollYfallback(インデックス推定は禁止)。 - 高さ seed: 測定した行高さを sessionStorage の
:hキーで永続 → reload 初フレームからgetTotalSize()が安定。 - min-height floor: reveal 後も placeholder を維持、実コンテンツが現在 scrollY を覆うときだけ除去。
- failsafe: unreachable anchor + cold-fetch 枯渇後に reveal — 永久
hiddenの白画面を防ぐ。
実務での適用
@tanstack/react-virtualのmeasureElementは commit 後 rAF 1 回で、テキスト行の補正に十分な場合が多い。- e2e: reload ±16px・ジッターなし、deep depth ドリフト ≤1px、sessionStorage 保存 gate、anchorless・unreachable 経路。
history.scrollRestoration = "manual"は Next ネイティブ back 復元と競合 — プロジェクト方針を確認。- ListRestoreBootstrap を外部 script ファイルに分離し CSP・キャッシュ方針を明確化。
トレードオフ
| 選択 | 利点 | 欠点 |
|---|---|---|
| 単一 corrective scroll | 予測可能、ガタつき除去 | 可変画像行は 1 回では不足することがある |
| N-frame reassert | ドリフト追従 | 目に見えるジッター・authority 競合 |
| インデックス fallback | 実装が容易 | カーソルページ境界でスナップ |
| ピクセル scrollY fallback | anchor なし時に安定 | 行削除時に誤差 |
| 測定高さの永続 | deep reload が正確 | sessionStorage 容量・マイグレーション |
使うべきでない場合
- 項目が数十件・仮想化なしの短いリスト(ブラウザ標準 restoration で十分)。
- 行高さが非同期画像で大きく変わるのに 1 回補正だけに固執する場合。
- スナップショット保存なしで e2e だけ「復元された」と通す場合。
よくある間違い
- throttle save effect の deps に毎レンダー新しい
captureSnapshotを入れ pending timer clear → 保存されない。 - estimateSize だけ使い deep reload 後 totalSize 減少 → scrollY が数百 px クランプ。
- bootstrap + React + inner seed が同時 scrollTo。
- e2e がネイティブ restoration だけ検証しスナップショット未保存バグを見逃す。
reassertScrollForFrames・estimateScrollYForRowIndexなど dead helper が残存。
関連概念
- list-virtualization-windowing — virtualizer・measureElement の基本
- ssr-hydration-mismatch — bootstrap script と React の境界