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