196 lines
5.2 KiB
TypeScript
196 lines
5.2 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { drop } from '../../../util/drop';
|
|
import { createLogger } from '../../../logging/log';
|
|
import * as Errors from '../../../types/errors';
|
|
import { strictAssert } from '../../../util/assert';
|
|
|
|
const log = createLogger('infinite');
|
|
|
|
export type InfiniteQueryLoader<Query, Page> = (
|
|
query: Query,
|
|
previousPage: Page | null,
|
|
signal: AbortSignal
|
|
) => Promise<Page>;
|
|
|
|
export type InfiniteQueryOptions<Query, Page> = Readonly<{
|
|
/** Important! Query must be memoized */
|
|
query: Query;
|
|
loader: InfiniteQueryLoader<Query, Page>;
|
|
hasNextPage: (query: Query, page: Page) => boolean;
|
|
}>;
|
|
|
|
export type InfiniteQueryState<Query, Page> = Readonly<{
|
|
query: Query;
|
|
pending: boolean;
|
|
rejected: boolean;
|
|
pages: ReadonlyArray<Page>;
|
|
hasNextPage: boolean;
|
|
}>;
|
|
|
|
export type InfiniteQueryApi<Query, Page> = Readonly<{
|
|
queryState: InfiniteQueryState<Query, Page>;
|
|
fetchNextPage: () => void;
|
|
revalidate: () => void;
|
|
}>;
|
|
|
|
export function useInfiniteQuery<Query, Page>(
|
|
options: InfiniteQueryOptions<Query, Page>
|
|
): InfiniteQueryApi<Query, Page> {
|
|
const loaderRef = useRef(options.loader);
|
|
const hasNextPageRef = useRef(options.hasNextPage);
|
|
useEffect(() => {
|
|
loaderRef.current = options.loader;
|
|
hasNextPageRef.current = options.hasNextPage;
|
|
}, [options.loader, options.hasNextPage]);
|
|
|
|
/**
|
|
* This is used to abort both the first page and the next page fetchers
|
|
* when the query changes.
|
|
*/
|
|
const querySignalRef = useRef<AbortSignal | null>(null);
|
|
|
|
const [edition, setEdition] = useState(0);
|
|
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
|
|
query: options.query,
|
|
pending: true,
|
|
rejected: false,
|
|
pages: [],
|
|
hasNextPage: false,
|
|
});
|
|
|
|
const stateRef = useRef(state);
|
|
const update = useCallback((next: InfiniteQueryState<Query, Page>) => {
|
|
stateRef.current = next;
|
|
setState(next);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
querySignalRef.current = signal;
|
|
|
|
let pendingStatusTimer: NodeJS.Timeout;
|
|
|
|
async function firstPageFetcher() {
|
|
// Show pending state faster if results are empty
|
|
const isEmpty = stateRef.current.pages.length === 0;
|
|
const showPendingStateDelay = isEmpty ? 50 : 300;
|
|
|
|
pendingStatusTimer = setTimeout(() => {
|
|
update({
|
|
query: options.query,
|
|
pending: true,
|
|
rejected: false,
|
|
pages: [],
|
|
hasNextPage: false,
|
|
});
|
|
}, showPendingStateDelay);
|
|
|
|
try {
|
|
const firstPage = await loaderRef.current(options.query, null, signal);
|
|
if (!signal.aborted) {
|
|
update({
|
|
query: options.query,
|
|
pending: false,
|
|
rejected: false,
|
|
pages: [firstPage],
|
|
hasNextPage: hasNextPageRef.current(options.query, firstPage),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
update({
|
|
...stateRef.current,
|
|
pending: false,
|
|
});
|
|
} else {
|
|
log.error('Error fetching first page', Errors.toLogFormat(error));
|
|
update({
|
|
query: options.query,
|
|
pending: false,
|
|
rejected: true,
|
|
pages: [],
|
|
hasNextPage: false,
|
|
});
|
|
}
|
|
} finally {
|
|
clearTimeout(pendingStatusTimer);
|
|
}
|
|
}
|
|
|
|
drop(firstPageFetcher());
|
|
|
|
return () => {
|
|
clearTimeout(pendingStatusTimer);
|
|
controller.abort();
|
|
};
|
|
}, [options.query, edition, update]);
|
|
|
|
const fetchNextPage = useCallback(() => {
|
|
strictAssert(
|
|
querySignalRef.current,
|
|
'Should have abort controller from first page fetcher'
|
|
);
|
|
|
|
if (querySignalRef.current.aborted) {
|
|
return;
|
|
}
|
|
|
|
const signal = querySignalRef.current;
|
|
|
|
async function nextPageFetcher() {
|
|
// Show pending state immediately
|
|
update({
|
|
...stateRef.current,
|
|
pending: true,
|
|
});
|
|
|
|
const { query, pages } = stateRef.current;
|
|
try {
|
|
const prevPage = pages.at(-1);
|
|
strictAssert(prevPage, 'Expected previous resolved page');
|
|
const nextPage = await loaderRef.current(query, prevPage, signal);
|
|
if (!signal.aborted) {
|
|
update({
|
|
query,
|
|
pending: false,
|
|
rejected: false,
|
|
pages: [...pages, nextPage],
|
|
hasNextPage: hasNextPageRef.current(query, nextPage),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
update({
|
|
...stateRef.current,
|
|
pending: false,
|
|
});
|
|
} else {
|
|
log.error('Error fetching next page', Errors.toLogFormat(error));
|
|
update({
|
|
query,
|
|
pending: false,
|
|
rejected: true,
|
|
pages,
|
|
hasNextPage: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
drop(nextPageFetcher());
|
|
}, [update]);
|
|
|
|
const revalidate = useCallback(() => {
|
|
setEdition(prevEdition => prevEdition + 1);
|
|
}, []);
|
|
|
|
return {
|
|
queryState: state,
|
|
fetchNextPage,
|
|
revalidate,
|
|
};
|
|
}
|