@@ -16,7 +16,7 @@
|
||||
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {moderatePost} from '@bsky/sdk/moderation'
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
useAutoPagination,
|
||||
} from '../util'
|
||||
import {type FeedPage} from './types'
|
||||
import {useUnreadNotificationsApi} from './unread'
|
||||
@@ -220,54 +221,9 @@ export function useNotificationFeedQuery(opts: {
|
||||
),
|
||||
})
|
||||
|
||||
// The server may end up returning an empty page, a page with too few items,
|
||||
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||
// we'll keep track of how many items we actually hope to see. If the server
|
||||
// doesn't return enough items, we're going to continue asking for more items.
|
||||
const lastItemCount = useRef(0)
|
||||
const wantedItemCount = useRef(0)
|
||||
const autoPaginationAttemptCount = useRef(0)
|
||||
useEffect(() => {
|
||||
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||
query
|
||||
// Count the items that we already have.
|
||||
let itemCount = 0
|
||||
for (const page of data?.pages || []) {
|
||||
itemCount += page.items.length
|
||||
}
|
||||
|
||||
// If items got truncated, reset the state we're tracking below.
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = itemCount
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
// Now track how many items we really want, and fetch more if needed.
|
||||
if (isLoading || isRefetching) {
|
||||
// During the initial fetch, we want to get an entire page's worth of items.
|
||||
wantedItemCount.current = PAGE_SIZE
|
||||
} else if (isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||
}
|
||||
} else if (hasNextPage) {
|
||||
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||
// If we didn't receive enough items from the server, paginate again until we do.
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
autoPaginationAttemptCount.current++
|
||||
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||
query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
autoPaginationAttemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
const itemCount =
|
||||
query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0
|
||||
useAutoPagination(query, itemCount, PAGE_SIZE)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type AtIdentifierString, AtUri, type AtUriString} from '@atproto/syntax'
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
useAutoPagination,
|
||||
} from './util'
|
||||
|
||||
type ActorDid = string
|
||||
@@ -368,56 +369,13 @@ export function usePostFeedQuery(
|
||||
),
|
||||
})
|
||||
|
||||
// The server may end up returning an empty page, a page with too few items,
|
||||
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||
// we'll keep track of how many items we actually hope to see. If the server
|
||||
// doesn't return enough items, we're going to continue asking for more items.
|
||||
const lastItemCount = useRef(0)
|
||||
const wantedItemCount = useRef(0)
|
||||
const autoPaginationAttemptCount = useRef(0)
|
||||
useEffect(() => {
|
||||
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||
query
|
||||
// Count the items that we already have.
|
||||
let itemCount = 0
|
||||
for (const page of data?.pages || []) {
|
||||
for (const slice of page.slices) {
|
||||
itemCount += slice.items.length
|
||||
}
|
||||
let itemCount = 0
|
||||
for (const page of query.data?.pages || []) {
|
||||
for (const slice of page.slices) {
|
||||
itemCount += slice.items.length
|
||||
}
|
||||
|
||||
// If items got truncated, reset the state we're tracking below.
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = itemCount
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
// Now track how many items we really want, and fetch more if needed.
|
||||
if (isLoading || isRefetching) {
|
||||
// During the initial fetch, we want to get an entire page's worth of items.
|
||||
wantedItemCount.current = MIN_POSTS
|
||||
} else if (isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||
wantedItemCount.current = itemCount + MIN_POSTS
|
||||
}
|
||||
} else if (hasNextPage) {
|
||||
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||
// If we didn't receive enough items from the server, paginate again until we do.
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
autoPaginationAttemptCount.current++
|
||||
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||
query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
autoPaginationAttemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
}
|
||||
useAutoPagination(query, itemCount, MIN_POSTS)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {renderHook} from '@testing-library/react-native'
|
||||
|
||||
import {useAutoPagination} from './util'
|
||||
|
||||
function query(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: {pageParams: [undefined]},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: true,
|
||||
fetchNextPage: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAutoPagination', () => {
|
||||
it('fetches another page when visible items are missing', () => {
|
||||
const value = query()
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops when the requested number of items is visible', () => {
|
||||
const value = query()
|
||||
|
||||
renderHook(() => useAutoPagination(value, 10, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when the server repeats a cursor', () => {
|
||||
const value = query({data: {pageParams: [undefined, 'a', 'a']}})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when structured page params repeat a cursor', () => {
|
||||
const value = query({
|
||||
data: {pageParams: [undefined, {cursor: 'a'}, {cursor: 'a'}]},
|
||||
})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when a cursor repeats non-adjacently', () => {
|
||||
const value = query({data: {pageParams: [undefined, 'a', 'b', 'a']}})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fills one page after switching to a smaller cached query', () => {
|
||||
const first = query({hasNextPage: false})
|
||||
const second = query()
|
||||
let value = first
|
||||
let itemCount = 10
|
||||
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
||||
|
||||
value = second
|
||||
itemCount = 5
|
||||
rerender(undefined)
|
||||
|
||||
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets the attempt limit after switching to cached data with the same item count', () => {
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined)
|
||||
const data = {pageParams: [undefined]}
|
||||
let value = query({fetchNextPage, data})
|
||||
const itemCount = 0
|
||||
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
||||
|
||||
for (let i = 1; i < 50; i++) {
|
||||
value = query({
|
||||
fetchNextPage,
|
||||
data,
|
||||
})
|
||||
rerender(undefined)
|
||||
}
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(49)
|
||||
|
||||
const second = query({
|
||||
data: {
|
||||
pageParams: Array.from({length: 51}, (_, i) => `new-cursor-${i}`),
|
||||
},
|
||||
})
|
||||
value = second
|
||||
rerender(undefined)
|
||||
|
||||
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {type AtUri} from '@atproto/syntax'
|
||||
import {
|
||||
type InfiniteData,
|
||||
@@ -8,6 +9,82 @@ import {
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
type AutoPaginationQuery = {
|
||||
data?: {pageParams: unknown[]}
|
||||
isLoading: boolean
|
||||
isRefetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
hasNextPage: boolean
|
||||
fetchNextPage: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function useAutoPagination(
|
||||
query: AutoPaginationQuery,
|
||||
itemCount: number,
|
||||
pageSize: number,
|
||||
) {
|
||||
const lastItemCount = useRef(0)
|
||||
const lastPageParams = useRef(query.data?.pageParams)
|
||||
const wantedItemCount = useRef(pageSize)
|
||||
const attemptCount = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const cursorOf = (param: unknown) =>
|
||||
param && typeof param === 'object' && 'cursor' in param
|
||||
? param.cursor
|
||||
: param
|
||||
const pageParams = query.data?.pageParams
|
||||
const previousPageParams = lastPageParams.current
|
||||
const continuedPagination =
|
||||
pageParams &&
|
||||
previousPageParams &&
|
||||
pageParams.length > previousPageParams.length &&
|
||||
previousPageParams.every((param, index) =>
|
||||
Object.is(cursorOf(param), cursorOf(pageParams[index])),
|
||||
)
|
||||
if (
|
||||
pageParams !== previousPageParams &&
|
||||
previousPageParams &&
|
||||
!continuedPagination
|
||||
) {
|
||||
wantedItemCount.current = pageSize
|
||||
attemptCount.current = 0
|
||||
}
|
||||
lastPageParams.current = pageParams
|
||||
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
attemptCount.current = 0
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = Math.max(itemCount, pageSize)
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
if (query.isLoading || query.isRefetching) {
|
||||
wantedItemCount.current = pageSize
|
||||
attemptCount.current = 0
|
||||
} else if (query.isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
wantedItemCount.current = itemCount + pageSize
|
||||
}
|
||||
} else if (query.hasNextPage) {
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
const currentCursor = cursorOf(pageParams?.at(-1))
|
||||
const repeatedCursor = pageParams
|
||||
?.slice(0, -1)
|
||||
.some(param => Object.is(cursorOf(param), currentCursor))
|
||||
if (repeatedCursor) return
|
||||
attemptCount.current++
|
||||
if (attemptCount.current < 50) {
|
||||
void query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
attemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [itemCount, pageSize, query])
|
||||
}
|
||||
|
||||
export type StructuredQueryKey<T extends Record<string, unknown>> = readonly [
|
||||
string,
|
||||
T,
|
||||
|
||||
Reference in New Issue
Block a user