概要
タイムラインのフィルタ(game / photo / link など)を切り替えたとき、 最新の投稿の一部が表示されなくなる「歯抜け」が発生していた。 リロードすると正常に全件表示されるため、クライアントサイドの状態管理の問題だと判断し、原因を特定・修正した。
現象
ブラウザコンソールのログを確認したところ、次のような状況が記録されていた。
API呼び出し: /api/timeline_feed?cursor=2026-03-25%2010%3A30%3A58&limit=1&filter=link
空のレスポンス、終了
リロード直後は以下のようになり、全4件が正常に取得できる。
API呼び出し: /api/timeline_feed?cursor=2026-03-29%2003%3A07%3A00&limit=1&filter=link
データ取得成功 → 1件追加
API呼び出し: /api/timeline_feed?cursor=2026-03-28%2000%3A13%3A55&limit=1&filter=link
データ取得成功 → 2件追加
[リンクフィルタ] 全件表示完了: 4 / 4
リロード後のカーソルは 2026-03-29 03:07:00 だが、
問題が発生したときのカーソルは 2026-03-25 10:30:58 と、
3〜4日分古い値になっていた。
原因
サーバーサイドフィルタを導入した際に、カーソルの管理に問題が生まれていた。
タイムラインの無限スクロールはカーソルベースのページネーションを使用している。 カーソルは「ここまで表示した」という最後のアイテムのタイムスタンプで、 次のリクエストは「このカーソルより古いアイテムを返す」という動作になる。
フィルタあり(例: &filter=game)で読み込みを行うと、
ゲーム投稿しか返らないため、ゲーム投稿が存在しない日付は丸ごとスキップされる。
この結果、カーソルが実際の表示範囲よりも大きく過去方向に進む。
初期SSRカーソル : 2026-03-29 03:07:00
gameフィルタ後 → : 2026-03-25 10:30:58 ← ゲームなしの日付を飛ばした
ここで別のフィルタ(link)に切り替えると、センチネルのカーソルはそのまま 2026-03-25 を指す。
&filter=link&cursor=2026-03-25... というリクエストを送ると、
2026-03-29〜03-26 のリンク投稿は「カーソルより新しい」ため返されず、
空レスポンスで終了してしまう。
修正内容
1. フィルタなしカーソルを別変数で保持する
unfilteredCursor という変数を追加し、
フィルタなしスクロール時のみこの変数を更新するようにした。
フィルタ付きスクロール時は更新しない。
// 初期値はSSRで設定されたカーソル
let unfilteredCursor: string = sentinel?.dataset.cursor || '';
// fetchNextBatch 内で:
const anyFilterNow = youtubeFilterActive || gameFilterActive || /* ... */;
if (!anyFilterNow && nextCursorVal) {
unfilteredCursor = nextCursorVal;
}
2. フィルタ切り替え時にカーソルを巻き戻す
カーソルが unfilteredCursor より古くなっていた場合(フィルタ付きスクロールで進んだ場合)、
unfilteredCursor に戻すヘルパー関数を追加した。
"YYYY-MM-DD HH:mm:ss" 形式は辞書順比較が時系列と一致するため、
文字列の < 演算子で新旧を判定できる。
const restoreUnfilteredCursor = () => {
if (
sentinel && unfilteredCursor &&
sentinel.dataset.cursor &&
sentinel.dataset.cursor < unfilteredCursor
) {
sentinel.dataset.cursor = unfilteredCursor;
}
};
この関数を以下の2箇所で呼び出す。
- フィルタ切り替え時(
resetScrollState内) - フィルタOFF時(各フィルタのトグル関数内)
3. 重複アイテムの除去
カーソルを巻き戻すと、過去フィルタでロード済みのアイテムが再度APIから返される場合がある。
data-entry-id 属性で突合し、既にDOMに存在するアイテムはスキップするようにした。
const entryId = importedNode.getAttribute('data-entry-id');
if (entryId && container.querySelector(`[data-entry-id="${entryId}"]`)) {
return; // 重複のためスキップ(forEachのreturnはcontinueと同義)
}
修正後の動作
ゲームフィルタでスクロールした後にリンクフィルタに切り替えても、 カーソルが初期SSRカーソルに巻き戻され、2026-03-29〜03-26 のリンク投稿が正常に取得される。
学び
- サーバーサイドフィルタはパフォーマンスに有効だが、カーソルの「意味」が変わることに注意が必要
- フィルタあり = フィルタに合致するアイテムのタイムスタンプでカーソルが進む
- フィルタなしの状態と、フィルタありの状態でカーソルを共有すると歯抜けが発生する
- 状態変数は「何のための状態か」を明確に分離して管理することが大切