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