概要
タイムラインの絞り込み機能(特定の種類の投稿だけを表示する機能)と無限スクロール(ページ下部に到達すると自動で次のデータを読み込む機能)を組み合わせた際に、画面に表示されない投稿を裏で延々と読み込み続けてしまう問題があった。
サーバー側で「フィルタ対象の投稿が全部で何件あるか」を事前に把握し、その件数がすべて画面に表示された時点で読み込みを即座に停止する方式に改修した。
背景
タイムラインには、特定の種類の投稿だけを表示するフィルタ機能がある。たとえば、ゲーム情報が設定されている投稿だけを表示したり、動画URLが含まれている投稿だけを表示したりする。
フィルタは「クライアント側の表示/非表示制御」で実現している。つまり、サーバーからはすべての投稿を取得し、フィルタ条件に合わないものを display: none で隠す。この設計はシンプルだが、無限スクロールとの相性が悪い。
発生していた問題
フィルタをONにしてページ下部までスクロールすると、以下のような挙動になっていた:
- スクロールが最下部に到達し、IntersectionObserverが発火
- 次のバッチをサーバーから取得
- 取得した投稿のうち、フィルタ条件に合わないものは非表示にする
- 非表示の要素は画面上のスペースを占有しないため、スクロール位置がまた最下部になる
- IntersectionObserverがまた発火し、手順2に戻る
この繰り返しが、サーバーに読み込むデータがなくなるまで続く。画面上にはローディングインジケータが表示され続け、最終的に何も表示されないまま「読み込み完了」になるという意味不明な挙動になっていた。
最初の修正(リトライループ方式)とその限界
初期の対応として「フィルタ有効時は、表示対象のアイテムが見つかるまで最大20回自動リトライする」方式を採用した。しかし、この方式にも問題があった。表示対象がすべて画面上に出揃った後でも、「次の表示対象を探して」リトライが発動し、対象がないバッチを取得し続ける。その間ローディングが表示され続け、ユーザーには何も起きていないように見える。
解決策:総件数チェック方式
根本的な解決のため、以下のアプローチを採用した。
サーバー側で総件数を事前取得
ページの初期レンダリング時(SSR フェーズ)に、データベースから「フィルタ対象の投稿が全部で何件あるか」を取得する。これを隠しDOM要素としてHTMLに埋め込み、クライアント側の JavaScript から参照できるようにする。
<!-- サーバーがレンダリング時に件数を埋め込む -->
<div id="filter-total" style="display:none" data-count="5"></div>
クライアント側の完了判定
「画面上に表示されているフィルタ対象の件数」と「総件数」を比較し、一致した時点で無限スクロールを即座に停止する。
function checkFilterComplete() {
const total = parseInt(
document.getElementById('filter-total')?.dataset.count || '0'
);
const displayed = document.querySelectorAll(
'.timeline-item[data-has-target="1"]:not([style*="display: none"])'
).length;
if (displayed >= total) {
// 読み込みを停止し、完了メッセージを表示
stopInfiniteScroll();
}
}
チェックを実行するタイミング
このチェックは3箇所で実行する:
- フィルタをONにした直後 — 初期表示だけで全件揃っている場合に即座に停止
- IntersectionObserverの発火直後(データ取得前) — すでに全件揃っていれば無駄な取得を防止
- データ取得の完了直後 — 新しいバッチで全件揃ったかどうかを判定
フィルタ解除時の復帰
フィルタをOFFにしたとき、まだ読み込むデータが残っている場合はIntersectionObserverを再度有効にし、通常の無限スクロールに復帰させる。
実装の横展開
この方式は汎用的なパターンであるため、新しいフィルタを追加する際にも同じ手順で対応できる。
- サーバー側でフィルタ対象の総件数を取得するクエリを追加
- 件数を格納する隠し要素をHTMLに配置
checkXxxFilterComplete()関数を実装- 統合チェック関数(
checkAnyFilterComplete())に新しいフィルタのチェックを追加 - フィルタのON/OFF切替時にIntersectionObserverの復帰処理を追加
この手順は、プロジェクト内のドキュメントとして記録した。今後のフィルタ追加時にこのパターンを参照できるようにしている。
技術的な学び
クライアント側フィルタと無限スクロールの組み合わせは、一見シンプルだが根本的に相性が悪い。フィルタで非表示にした要素がスペースを占有しないため、スクロール位置の計算が狂い、IntersectionObserverが予期しないタイミングで発火し続ける。
理想的にはサーバー側でフィルタ済みのデータだけを返す API を用意するのが正攻法だが、既存のタイムライン API(複数のデータソースを結合している)の構造を大きく変えることなく対応するために、「総件数チェック」というアプローチを選択した。この方式であれば、既存のデータ取得ロジックに手を入れずに、フィルタ固有の停止条件だけを追加すればよい。