fix: stop feeds from stalling on filtered-out pages
a feed gave up whenever a page's items were all filtered out client-side, and `CustomFeedAPI` suppressed the cursor on an empty page, so a feed could report itself exhausted while more pages existed upstream. one empty page is the wrong predicate for exhaustion — the appview legitimately returns an empty page with a cursor when hydration drops everything. extract the paginator both feeds had copied into `useAutoPagination`, and make exhaustion the concern of `getNextPageParam`, which is where the next page param is minted and where a cursor the server has already served can be recognised. `hasNextPage` then tells the truth for manual scrolling too, not just for the auto-paginator.
11234531 unmodified lines373839404142434445113 unmodified linesExpand all15916016116216216316416516616716816316416516616716816917017017117217386 unmodified lines260261262263265266267268269270271272273264265266267274268269270271277278279280281282283284285286287288289290291292293294295296297298299300301302272273274275180 unmodified linesExpand allimport { useEffect, useRef } from 'react';import { useRef } from 'react';
import type { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky';import { DisplayContext,31 unmodified lines
import { useModerationOpts } from '../moderation/moderation-opts';import { useFeedTuners } from './feed-tuners';import { usePreferencesQuery } from './preferences';import { useAutoPagination } from './use-auto-pagination';import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost } from './util';
type RQPageParam = { cursor: string | undefined; api: FeedAPI } | undefined;
113 unmodified linesExpand all fetchedAt: Date.now(), }; }, initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.cursor ? { api: lastPage.api, cursor: lastPage.cursor, } : undefined, getNextPageParam: (lastPage, _allPages, _lastPageParam, allPageParams) => { if (!lastPage.cursor || allPageParams.some((param) => param?.cursor === lastPage.cursor)) { return undefined; } return { api: lastPage.api, cursor: lastPage.cursor }; }, select: (data: InfiniteData<FeedPageUnselected, RQPageParam>) => { // read selector inputs from the stable object to avoid stale closures. // oxlint-disable-next-line no-shadow -- shadowing is the point: it stops the callback from reading a stale closure copy instead of `selectArgs` const { feedTuners, moderationOpts, ignoreFilterFor } = selectArgs;
const tuner = new FeedTuner(feedTuners);86 unmodified lines }, });
// fetch more pages when filtering leaves fewer items than requested. const lastItemCount = useRef(0); const wantedItemCount = useRef(0); const autoPaginationAttemptCount = useRef(0); useEffect(() => { const { data, isLoading, isRefetching, isFetchingNextPage, hasNextPage } = query; 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; } } } useAutoPagination({ query, itemCount, pageSize: MIN_POSTS });
if (itemCount !== lastItemCount.current) { if (itemCount < lastItemCount.current) { wantedItemCount.current = itemCount; } lastItemCount.current = itemCount; }
if (isLoading || isRefetching) { wantedItemCount.current = MIN_POSTS; } else if (isFetchingNextPage) { if (itemCount > wantedItemCount.current) { // account for pages requested by another caller. wantedItemCount.current = itemCount + MIN_POSTS; } } else if (hasNextPage) { if (itemCount < wantedItemCount.current) { autoPaginationAttemptCount.current++; if (autoPaginationAttemptCount.current < 50 /* fail-safe */) { void query.fetchNextPage(); } } else { autoPaginationAttemptCount.current = 0; } } }, [query]);
return query;}
export async function pollLatest(page: FeedPage | undefined) {180 unmodified linesExpand all1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889import { useEffect, useRef } from 'react';
interface AutoPaginationQuery { data?: { pageParams: readonly unknown[] }; isLoading: boolean; isRefetching: boolean; isFetchingNextPage: boolean; hasNextPage: boolean; fetchNextPage: () => Promise<unknown>;}
const MAX_AUTO_PAGINATION_ATTEMPTS = 50;
/** * fetches pages until the filtered item count reaches `pageSize`. * * @param options.query infinite query to paginate * @param options.itemCount filtered item count * @param options.pageSize target item count */export function useAutoPagination({ query, itemCount, pageSize,}: { 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 pageParams = query.data?.pageParams; const previousPageParams = lastPageParams.current;
// appended pages retain the existing parameter prefix. const continuedPagination = pageParams !== undefined && previousPageParams !== undefined && pageParams.length > previousPageParams.length && previousPageParams.every((param, index) => Object.is(param, pageParams[index]));
if (pageParams !== previousPageParams && previousPageParams !== undefined && !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; return; }
if (query.isFetchingNextPage) { if (itemCount > wantedItemCount.current) { wantedItemCount.current = itemCount + pageSize; } return; }
if (!query.hasNextPage) { return; }
if (itemCount >= wantedItemCount.current) { attemptCount.current = 0; return; }
if (attemptCount.current >= MAX_AUTO_PAGINATION_ATTEMPTS) { return; }
attemptCount.current++; void query.fetchNextPage(); }, [itemCount, pageSize, query]);}53 unmodified lines5455565758585960616253 unmodified lines if (feed.length > limit) { feed = feed.slice(0, limit); } return { cursor: feed.length ? data.cursor : undefined, cursor: data.cursor, feed, }; }}3 unmodified lines456788910111213 unmodified lines26272829303132333459 unmodified lines9495969797989910010110210310410510610771 unmodified lines17918018118217717817918018118218318418518318418518718818919019119219319419519619719819920020120220320420520620720820921021121218618718818973 unmodified lines3 unmodified lines * to sync latest data, call `checkUnread()` instead of refetching this query. use `checkUnread({invalidate: * true})` to immediately sync latest results. */
import { useEffect, useRef } from 'react';import { useRef } from 'react';
import type { AnyProfileView, AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky';import { DisplayContext, getDisplayRestrictions, moderatePost } from '@atcute/bluesky-moderation';import { parseResourceUri } from '@atcute/lexicons/syntax';13 unmodified linesimport { STALE } from '#/state/queries';import { getClients } from '#/state/session';import { useHiddenReplyUris } from '#/state/threadgate-hidden-replies';
import { useAutoPagination } from '../use-auto-pagination';import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost } from '../util';import { NOTIFICATION_FEED_RQKEY_ROOT, notificationFeedQueryKey } from './notification-feed-key';import type { FeedPage } from './types';import { useUnreadNotificationsApi } from './unread';59 unmodified lines
return page; }, initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.cursor, getNextPageParam: (lastPage, _allPages, _lastPageParam, allPageParams) => { if (!lastPage.cursor || allPageParams.includes(lastPage.cursor)) { return undefined; } return lastPage.cursor; }, select: (data: InfiniteData<FeedPage>) => { // oxlint-disable-next-line no-shadow -- shadowing is the point: it stops the callback from reading a stale closure copy instead of `selectArgs` const { moderationOpts, hiddenReplyUris } = selectArgs;
71 unmodified lines }, });
// fetch more pages when filtering leaves fewer items than requested. const lastItemCount = useRef(0); const wantedItemCount = useRef(0); const autoPaginationAttemptCount = useRef(0); useEffect(() => { const { data, isLoading, isRefetching, isFetchingNextPage, hasNextPage } = query; let itemCount = 0; for (const page of data?.pages || []) { itemCount += page.items.length; } const itemCount = query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0; useAutoPagination({ query, itemCount, pageSize: PAGE_SIZE });
if (itemCount !== lastItemCount.current) { if (itemCount < lastItemCount.current) { wantedItemCount.current = itemCount; } lastItemCount.current = itemCount; }
if (isLoading || isRefetching) { wantedItemCount.current = PAGE_SIZE; } else if (isFetchingNextPage) { if (itemCount > wantedItemCount.current) { // account for pages requested by another caller. wantedItemCount.current = itemCount + PAGE_SIZE; } } else if (hasNextPage) { if (itemCount < wantedItemCount.current) { autoPaginationAttemptCount.current++; if (autoPaginationAttemptCount.current < 50 /* fail-safe */) { void query.fetchNextPage(); } } else { autoPaginationAttemptCount.current = 0; } } }, [query]);
return query;}
export function* findAllPostsInQueryData(73 unmodified lines