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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user