feat: switch frontend to cursor-based pagination

Replace page-number walking with cursor chaining in usePhotosQuery.
Each response includes a next_cursor that seeks directly to the next
slice via an indexed range scan — O(1) regardless of depth instead of
OFFSET-based skipping that degrades on large libraries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:56:57 +02:00
parent 348e9c3585
commit 8f41a23c41

View File

@@ -20,6 +20,19 @@ export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) {
})
}
interface CursorPage {
photos: Photo[]
next_cursor: string | null
}
async function fetchCursorPage(
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<CursorPage> {
const resp = await api.get<CursorPage>('/photos', { params, signal })
return resp.data
}
/**
* Single source of truth for the timeline photos query. Both Timeline and
* PreviewView call this so they share one cache entry — previously
@@ -70,55 +83,41 @@ export function usePhotosQuery() {
return useQuery({
queryKey: ['photos', filterParams],
queryFn: async ({ signal }) => {
// Two-phase fetch so the timeline can paint its first thumbnails
// long before the entire library has finished downloading. Phase 1
// returns the first page synchronously (which resolves the
// useQuery promise so consumers exit their loading state). Phase 2
// walks the remaining pages in the background, appending each one
// into the cache via setQueryData so the grid grows as data
// arrives. The signal from React Query aborts the background
// loop if the query is invalidated or unmounts mid-stream.
// Two-phase fetch using cursor-based (keyset) pagination.
// Phase 1 returns the first page (resolves the useQuery promise
// so consumers exit loading state). Phase 2 chains cursors in
// the background — each response includes a `next_cursor` that
// seeks directly to the next slice via an indexed range scan,
// O(1) regardless of depth (no OFFSET skipping).
const PER_PAGE = 500
const MAX_PAGES = 200
const firstResp = await api.get<{
photos: Photo[]
total: number
pages: number
}>('/photos', {
params: { page: 1, per_page: PER_PAGE, ...filterParams },
const first = await fetchCursorPage(
{ per_page: PER_PAGE, ...filterParams },
signal,
})
const firstBatch = firstResp.data.photos || []
const totalPages = firstResp.data.pages ?? 1
)
const firstBatch = first.photos || []
let nextCursor: string | null = first.next_cursor
if (totalPages > 1 && firstBatch.length === PER_PAGE) {
// Fire-and-forget background loop. We don't await here — the
// first batch is already enough to render. Each subsequent
// page lands via setQueryData, which triggers consumers to
// re-render with the larger list.
if (nextCursor) {
// Fire-and-forget background loop using cursor chaining.
void (async () => {
for (let page = 2; page <= Math.min(totalPages, MAX_PAGES); page++) {
for (let i = 0; i < MAX_PAGES && nextCursor; i++) {
if (signal?.aborted) return
try {
const resp = await api.get<{
photos: Photo[]
total: number
pages: number
}>('/photos', {
params: { page, per_page: PER_PAGE, ...filterParams },
const page = await fetchCursorPage(
{ per_page: PER_PAGE, cursor: nextCursor, ...filterParams },
signal,
})
)
if (signal?.aborted) return
const more = resp.data.photos || []
const more = page.photos || []
nextCursor = page.next_cursor
queryClient.setQueryData<Photo[]>(
['photos', filterParams],
(prev) => (prev ? [...prev, ...more] : more)
)
if (more.length < PER_PAGE) return
if (!nextCursor || more.length < PER_PAGE) return
} catch {
// Network or abort — give up the background stream. The
// next user-triggered refetch will start fresh.
return
}
}