Skip to content

feat: implement keyset cursor pagination for note pages (issue #860 Phase 3)#866

Merged
otomatty merged 2 commits into
developfrom
claude/issue-860-phase3-7N6Rc
May 13, 2026
Merged

feat: implement keyset cursor pagination for note pages (issue #860 Phase 3)#866
otomatty merged 2 commits into
developfrom
claude/issue-860-phase3-7N6Rc

Conversation

@otomatty

@otomatty otomatty commented May 13, 2026

Copy link
Copy Markdown
Owner

概要

ノートのページ一覧取得を keyset cursor pagination で段階取得する機能を実装しました。useInfiniteNotePages フックを新規追加し、React Query の useInfiniteQuery を使用して大規模ノートでも効率的にページを読み込めるようにしました。

変更点

  • 新規フック useInfiniteNotePages: keyset cursor pagination で段階取得するフック。fetchNextPage() で次の window を取得でき、仮想スクロール対応が容易
  • API クライアント拡張: getNotePages() メソッドを追加。cursor, limit, include パラメータをサポート
  • Query key ファクトリ拡張: noteKeys.pagesWindow()noteKeys.pagesWindowByNoteId() を追加。include / pageSize の組み合わせごとにキャッシュを分離
  • PageGrid 更新: useNotePages() から useInfiniteNotePages() に切り替え。仮想スクロール末尾検知で自動的に次の window を取得
  • Mutation 後の無効化: ページ追加・削除時に pagesWindowByNoteId キーで全 window キャッシュを無効化
  • NoteAddPageDialog 簡素化: 全ページ配列の依存を削除。note-scoped 検索 API への移行を見据えた設計

変更の種類

  • ✨ 新機能 (New feature)
  • 🎨 スタイル/リファクタリング (Style/Refactor)
  • 🧪 テスト (Tests)

テスト方法

  1. bun run test で単体テストが全てパス
    • useInfiniteNotePages.test.tsx: cursor スレッド、フラット化、include / pageSize 反映を検証
    • apiClient.test.ts: getNotePages() のクエリパラメータ エンコーディングを検証
    • PageGrid.test.tsx: 仮想スクロール末尾検知で fetchNextPage() が呼ばれることを検証
  2. bun run lintbun run format:check が通る
  3. 大規模ノート(100+ ページ)でスクロール時に段階取得が動作することを確認

チェックリスト

  • テストがすべてパスする
  • Lint エラーがない
  • 必要に応じてドキュメントを更新した(JSDoc / TSDoc を併記)
  • コミットメッセージが Conventional Commits に従っている

関連 Issue

Closes #860 Phase 3

https://claude.ai/code/session_013vsh2Xaig91WcEvsKCzzDb

Summary by CodeRabbit

  • New Features

    • Note pages support infinite, cursor-based loading with on-demand fetching as you scroll.
  • Refactor

    • Page grid now relies on server ordering for note-scoped views and virtualized loading for reduced rendering.
  • Behavior

    • Add-page dialog checks available pages from the global list (duplicate-title logic updated).
  • Tests

    • Expanded test coverage for pagination, API page retrieval, and infinite scrolling behavior.

Review Change Stack

…Phase 3)

PageGrid now consumes `GET /api/notes/:noteId/pages` via the new
`useInfiniteNotePages` hook (keyset cursor pagination from Phase 1/2). The
virtualizer triggers `fetchNextPage()` when the visible range approaches the
loaded tail, so initial render is bounded to one window regardless of note
size. Server order (`updated_at DESC, id DESC`) is trusted verbatim; the
client only reapplies sort/filter for personal pages backed by IndexedDB.

NoteAddPageDialog drops the `notePages: PageSummary[]` prop. The dedup filter
was a post-#823 no-op (note-native ids never collide with personal page ids),
so removing it severs the last full-array dependency from the note view.

Mutations (`useAddPageToNote`, `useCopyPersonalPageToNote`,
`useRemovePageFromNote`) now also invalidate `noteKeys.pagesWindowByNoteId`
so the windowed cache refreshes after add / copy / remove.

https://claude.ai/code/session_013vsh2Xaig91WcEvsKCzzDb
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7df8357a-0851-4547-a051-b0002e0effa5

📥 Commits

Reviewing files that changed from the base of the PR and between 3da839b and 530ad55.

📒 Files selected for processing (2)
  • src/components/page/PageGrid.tsx
  • src/hooks/useNoteQueries.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/components/page/PageGrid.tsx
  • src/hooks/useNoteQueries.ts

📝 Walkthrough

Walkthrough

Adds keyset cursor pagination for note page windows: new API types and client getNotePages, a typed useInfiniteNotePages hook (React Query infinite), PageGrid integration that fetches next windows when virtual scroll nears the tail, tests, and removal of legacy full-array note-pages plumbing.

Changes

Infinite Cursor Pagination for Note Pages

Layer / File(s) Summary
API types and REST contract for note page windows
src/lib/api/types.ts
Adds NotePageWindowInclude ("preview" | "thumbnail"), NotePageWindowItem with nullable content_preview/thumbnail_url, and NotePageWindowResponse { items, next_cursor }.
API client and tests for getNotePages keyset pagination endpoint
src/lib/api/apiClient.ts, src/lib/api/apiClient.test.ts
Implements getNotePages on the API client with auth-optional GET /api/notes/:noteId/pages, cursor/limit/include query handling and include de-duplication; tests validate query construction and response parsing.
useInfiniteNotePages hook with React Query infinite query and cache keys
src/hooks/useNoteQueries.ts
Adds noteKeys.pagesWindow/pagesWindowByNoteId, defaults/options, notePageWindowItemToSummary mapper, and useInfiniteNotePages(noteId, options) using useInfiniteQuery to flatten window pages and expose controls.
useInfiniteNotePages test suite
src/hooks/useInfiniteNotePages.test.tsx
Tests initial cursor=null request with default include/limit, cursor pass-through for subsequent pages, flattening of pages, enabled:false gate, and empty-noteId behavior.
PageGrid integration with infinite note pages and virtual-scroll tail detection
src/components/page/PageGrid.tsx, src/components/page/PageGrid.test.tsx
Replaces useNotePages with useInfiniteNotePages for note context, trusts server ordering (updated_at DESC, id DESC), adds INFINITE_FETCH_THRESHOLD_ROWS and effect to call fetchNextPage() when virtualized tail nears loaded data; tests updated to use infinite-query mock shape and assert pagination triggers and virtualization bounds.
Mutation cache invalidation for note page modifications
src/hooks/useNoteQueries.ts
useAddPageToNote, useCopyPersonalPageToNote, and useRemovePageFromNote now invalidate noteKeys.pagesWindowByNoteId(noteId) so windowed infinite caches refresh after mutations.
Remove legacy useNotePages and simplify NoteAddPageDialog plumbing
src/pages/NoteView/index.tsx, src/pages/NoteView/NoteView.test.tsx, src/pages/NoteView/NoteAddPageDialog.tsx
Drops useNotePages from NoteView, removes notePages prop from NoteAddPageDialog and filters allPages directly; updates tests to match new data flow.

Sequence Diagram

sequenceDiagram
  participant PageGrid
  participant useInfiniteNotePages
  participant ReactQuery
  participant ApiClient
  PageGrid->>useInfiniteNotePages: call hook with noteId
  useInfiniteNotePages->>ReactQuery: useInfiniteQuery(cursor=null)
  ReactQuery->>ApiClient: GET /api/notes/:noteId/pages?cursor=null&limit=50&include=...
  ApiClient-->>ReactQuery: { items, next_cursor }
  ReactQuery-->>useInfiniteNotePages: pages, hasNextPage, fetchNextPage
  useInfiniteNotePages-->>PageGrid: flattened pages array + controls

  PageGrid->>PageGrid: detect virtual range near tail
  alt hasNextPage && !isFetchingNextPage
    PageGrid->>useInfiniteNotePages: fetchNextPage()
    useInfiniteNotePages->>ReactQuery: fetch next cursor
    ReactQuery->>ApiClient: GET /api/notes/:noteId/pages?cursor=...
    ApiClient-->>ReactQuery: { items, next_cursor }
    ReactQuery-->>PageGrid: append pages to state
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • otomatty/zedi#865: Server-side keyset pagination implementation for GET /api/notes/:noteId/pages endpoint that this PR depends on for the cursor/index contract.
  • otomatty/zedi#838: Prior PR that introduced note-scoped useNotePages to PageGrid, which this PR replaces with an infinite-query approach and virtual-scroll tail detection.
  • otomatty/zedi#715: Changes to useNoteQueries and noteKeys that overlap with this PR’s cache-key and hook modifications.

"I hopped through code with tiny feet,
Pages once heavy, now fetched in cheat-sheet,
Tail-sniffing scroll fetches more with grace,
Cards appear swift in their ordered place,
A rabbit cheers for infinite-space! 🐇"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: implementing keyset cursor pagination for note pages as part of issue #860 Phase 3.
Linked Issues check ✅ Passed All Phase 3 coding objectives are met: useInfiniteNotePages hook added with cursor pagination, PageGrid replaced with infinite query, virtualizer triggers fetchNextPage, server ordering trusted, and NoteAddPageDialog dependency removed.
Out of Scope Changes check ✅ Passed All changes align with Phase 3 objectives. No unrelated modifications detected; refactoring is scoped to infinite pagination implementation and necessary test updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/issue-860-phase3-7N6Rc

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/hooks/useNoteQueries.ts (1)

80-95: ⚡ Quick win

Normalize include tokens in pagesWindow keys to avoid cache fragmentation.

The key currently sorts but does not de-duplicate include, while the request layer de-duplicates before sending. That can create multiple cache entries for the same effective query.

♻️ Proposed fix
   pagesWindow: (
     noteId: string,
     userId: string,
     userEmail: string | undefined,
     include: ReadonlyArray<NotePageWindowInclude>,
     pageSize: number,
   ) =>
+    {
+      const includeKey = Array.from(new Set(include)).sort().join(",");
+      return [
+        ...noteKeys.pages(),
+        "window",
+        noteId,
+        userId,
+        userEmail ?? "",
+        includeKey,
+        pageSize,
+      ] as const;
+    },
-    [
-      ...noteKeys.pages(),
-      "window",
-      noteId,
-      userId,
-      userEmail ?? "",
-      [...include].sort().join(","),
-      pageSize,
-    ] as const,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useNoteQueries.ts` around lines 80 - 95, pagesWindow key generation
sorts the include tokens but doesn't remove duplicates, causing cache
fragmentation; update the pagesWindow implementation to first de-duplicate the
include array (e.g., using new Set or similar) then sort and join it (replace
[...include].sort().join(",") with something like Array.from(new
Set(include)).sort().join(",")) so the key represents the same effective query
as the request layer; ensure you reference the pagesWindow function and keep the
rest of the tuple unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useNoteQueries.ts`:
- Line 629: The JSDoc comment currently only contains Japanese ("ISO 文字列 → ms
整数。パースできなければ 0 を返す。"); add an English counterpart in the same comment block so
it is bilingual — e.g. append or prepend "ISO string → milliseconds (number).
Returns 0 if parsing fails." — ensuring the comment remains a single /** ... */
doc comment above the function that converts ISO strings to ms.

---

Nitpick comments:
In `@src/hooks/useNoteQueries.ts`:
- Around line 80-95: pagesWindow key generation sorts the include tokens but
doesn't remove duplicates, causing cache fragmentation; update the pagesWindow
implementation to first de-duplicate the include array (e.g., using new Set or
similar) then sort and join it (replace [...include].sort().join(",") with
something like Array.from(new Set(include)).sort().join(",")) so the key
represents the same effective query as the request layer; ensure you reference
the pagesWindow function and keep the rest of the tuple unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 481196da-880d-4023-aa74-56c1f20e4adf

📥 Commits

Reviewing files that changed from the base of the PR and between 12151a7 and 3da839b.

📒 Files selected for processing (10)
  • src/components/page/PageGrid.test.tsx
  • src/components/page/PageGrid.tsx
  • src/hooks/useInfiniteNotePages.test.tsx
  • src/hooks/useNoteQueries.ts
  • src/lib/api/apiClient.test.ts
  • src/lib/api/apiClient.ts
  • src/lib/api/types.ts
  • src/pages/NoteView/NoteAddPageDialog.tsx
  • src/pages/NoteView/NoteView.test.tsx
  • src/pages/NoteView/index.tsx

Comment thread src/hooks/useNoteQueries.ts Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements keyset cursor pagination for note pages (Issue #860 Phase 3). It introduces the useInfiniteNotePages hook, adds a corresponding getNotePages method to the API client, and updates the PageGrid component to support infinite scrolling via TanStack Virtual. Additionally, redundant page filtering logic was removed from the NoteAddPageDialog. Feedback was provided regarding the efficiency of a useEffect dependency in PageGrid.tsx, suggesting that depending on the last visible index instead of the entire virtualRows array would prevent unnecessary effect executions during scrolling.

Comment on lines +260 to +276
useEffect(() => {
if (!isNoteContext) return;
if (!noteInfinite.hasNextPage || noteInfinite.isFetchingNextPage) return;
if (virtualRows.length === 0) return;
const lastVisibleRow = virtualRows[virtualRows.length - 1];
if (!lastVisibleRow) return;
if (lastVisibleRow.index >= rowCount - INFINITE_FETCH_THRESHOLD_ROWS) {
noteInfinite.fetchNextPage();
}
}, [
isNoteContext,
noteInfinite.hasNextPage,
noteInfinite.isFetchingNextPage,
noteInfinite.fetchNextPage,
virtualRows,
rowCount,
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The useEffect dependency array includes virtualRows, which is an array returned by rowVirtualizer.getVirtualItems(). This array is recreated on every scroll event, causing the effect to run very frequently. While the internal logic is guarded, it's more efficient to depend only on the specific value that triggers the fetch, such as the index of the last visible row.

Suggested change
useEffect(() => {
if (!isNoteContext) return;
if (!noteInfinite.hasNextPage || noteInfinite.isFetchingNextPage) return;
if (virtualRows.length === 0) return;
const lastVisibleRow = virtualRows[virtualRows.length - 1];
if (!lastVisibleRow) return;
if (lastVisibleRow.index >= rowCount - INFINITE_FETCH_THRESHOLD_ROWS) {
noteInfinite.fetchNextPage();
}
}, [
isNoteContext,
noteInfinite.hasNextPage,
noteInfinite.isFetchingNextPage,
noteInfinite.fetchNextPage,
virtualRows,
rowCount,
]);
const lastVirtualIndex = virtualRows.length > 0 ? virtualRows[virtualRows.length - 1].index : -1;
useEffect(() => {
if (!isNoteContext) return;
if (!noteInfinite.hasNextPage || noteInfinite.isFetchingNextPage) return;
if (lastVirtualIndex === -1) return;
if (lastVirtualIndex >= rowCount - INFINITE_FETCH_THRESHOLD_ROWS) {
noteInfinite.fetchNextPage();
}
}, [
isNoteContext,
noteInfinite.hasNextPage,
noteInfinite.isFetchingNextPage,
noteInfinite.fetchNextPage,
lastVirtualIndex,
rowCount,
]);

- Depend on `lastVirtualIndex` (scalar) instead of the `virtualRows` array
  in `PageGrid`'s tail-detection effect. `getVirtualItems()` returns a fresh
  array on every scroll, so the previous dependency made the effect re-run
  every frame even when the trigger condition could not have changed
  (gemini-code-assist on PR #866).
- Replace the misplaced JP-only docstring on `notePageWindowItemToSummary`
  in `useNoteQueries.ts`. The old comment described `parseTs`, not this
  mapper. Now a proper bilingual JSDoc explains the snake_case → camelCase
  mapping and why `addedByUserId` is sourced from `owner_id` post-#823
  (coderabbitai on PR #866).

https://claude.ai/code/session_013vsh2Xaig91WcEvsKCzzDb
@otomatty otomatty merged commit b6230c7 into develop May 13, 2026
16 checks passed
@otomatty otomatty deleted the claude/issue-860-phase3-7N6Rc branch May 13, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Epic] 超巨大ノート対応: ページ一覧の段階取得とプレビュー配信の再設計

2 participants