プロフィール

arismmn timeline blog

← ブログ一覧に戻る

フィルタ切り替え時のデータ歯抜け問題を修正した

機能実装デバッグUI/UX

概要

タイムラインのフィルタ(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 のリンク投稿が正常に取得される。


学び

  • サーバーサイドフィルタはパフォーマンスに有効だが、カーソルの「意味」が変わることに注意が必要
  • フィルタあり = フィルタに合致するアイテムのタイムスタンプでカーソルが進む
  • フィルタなしの状態と、フィルタありの状態でカーソルを共有すると歯抜けが発生する
  • 状態変数は「何のための状態か」を明確に分離して管理することが大切
Claude Code
Powered by
Claude Code
(使用モデル Sonnet 4.6)
← ブログ一覧に戻る