diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index d8e831d..8460330 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -20,6 +20,19 @@ export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) { }) } +interface CursorPage { + photos: Photo[] + next_cursor: string | null +} + +async function fetchCursorPage( + params: Record, + signal?: AbortSignal, +): Promise { + const resp = await api.get('/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( ['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 } }