convex
Version:
Client for the Convex Cloud
401 lines (380 loc) • 12.4 kB
text/typescript
import { convexToJson, Value } from "../values/index.js";
import { useMemo, useState } from "react";
import { RequestForQueries, useQueriesGeneric } from "./use_queries.js";
import { GenericAPI, NamedQuery } from "../api/index.js";
import { OptimisticLocalStore } from "../browser/index.js";
import { PaginationOptions, PaginationResult } from "../server/index.js";
import { PickByValue } from "../type_utils.js";
/**
* Load data reactively from a paginated query to a create a growing list.
*
* This can be used to power "infinite scroll" UIs.
*
* This hook must be used with Convex query functions that match
* {@link PaginatedQueryFunction}. This means they must:
* 1. Have a first argument must be an object containing `numItems` and `cursor`.
* 2. Return a {@link server.PaginationResult}.
*
* `usePaginatedQueryGeneric` concatenates all the pages
* of results into a single list and manages the continuation cursors when
* requesting more items.
*
* Example usage:
* ```typescript
* const { results, status, loadMore } = usePaginatedQueryGeneric(
* "listMessages",
* { initialNumItems: 5 },
* "#general"
* );
* ```
*
* If the query `name` or `args` change, the pagination state will be reset
* to the first page. Similarly, if any of the pages result in an InvalidCursor
* or QueryScannedTooManyDocuments error, the pagination state will also reset
* to the first page.
*
* To learn more about pagination, see [Paginated Queries](https://docs.convex.dev/using/pagination).
*
* If you're using code generation, use the `usePaginatedQuery` function in
* `convex/_generated/react.js` which is typed for your API.
*
* @param name - The name of the query function.
* @param options - An object specifying the `initialNumItems` to be loaded in
* the first page.
* @param args - The arguments to the query function, excluding the first.
* @returns A {@link UsePaginatedQueryResult} that includes the currently loaded
* items, the status of the pagination, and a `loadMore` function.
*
* @public
*/
export function usePaginatedQueryGeneric(
name: string,
options: { initialNumItems: number },
...args: Value[]
): UsePaginatedQueryResult<any> {
const createInitialState = useMemo(() => {
return () => {
const id = nextPaginationId();
return {
name,
args,
id,
maxQueryIndex: 0,
queries: {
0: {
name,
args: [
{
numItems: options.initialNumItems,
cursor: null,
id,
},
...args,
],
},
},
};
};
// ESLint doesn't like that we're stringifying the args. We do this because
// we want to avoid rerendering if the args are a different
// object that serializes to the same result.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(convexToJson(args)), name, options.initialNumItems]);
const [state, setState] = useState<{
name: string;
args: Value[];
id: number;
maxQueryIndex: number;
queries: RequestForQueries;
}>(createInitialState);
// `currState` is the state that we'll render based on.
let currState = state;
if (
name !== state.name ||
JSON.stringify(convexToJson(args)) !==
JSON.stringify(convexToJson(state.args))
) {
currState = createInitialState();
setState(currState);
}
const resultsObject = useQueriesGeneric(currState.queries);
const [results, maybeLastResult]: [
Value[],
undefined | PaginationResult<Value>
] = useMemo(() => {
let currResult = undefined;
const allItems = [];
for (let i = 0; i <= currState.maxQueryIndex; i++) {
currResult = resultsObject[i];
if (currResult === undefined) {
break;
}
if (currResult instanceof Error) {
if (
currResult.message.includes("InvalidCursor") ||
currResult.message.includes("QueryScannedTooManyDocuments")
) {
// `useInifinteQuery` handles a few types of query errors:
// 1. InvalidCursor: If the cursor is invalid, probably the paginated
// database query was data-dependent and changed underneath us. The
// cursor in the params or journal no longer matches the current
// database query.
// 2. QueryScannedTooManyDocuments: Likely so many elements were
// added to a single page they hit our limit.
// In both cases we want to restart pagination to throw away all our
// existing cursors.
setState(createInitialState);
return [[], undefined];
} else {
throw currResult;
}
}
allItems.push(...currResult.page);
}
return [allItems, currResult];
}, [resultsObject, currState.maxQueryIndex, createInitialState]);
const statusAndLoadMore = useMemo(() => {
if (maybeLastResult === undefined) {
return {
status: "LoadingMore",
loadMore: undefined,
} as const;
}
if (maybeLastResult.isDone) {
return {
status: "Exhausted",
loadMore: undefined,
} as const;
}
const continueCursor = maybeLastResult.continueCursor;
let alreadyLoadingMore = false;
return {
status: "CanLoadMore",
loadMore: (numItems: number) => {
if (!alreadyLoadingMore) {
alreadyLoadingMore = true;
setState(prevState => {
const maxQueryIndex = prevState.maxQueryIndex + 1;
const queries = { ...prevState.queries };
queries[maxQueryIndex] = {
name: prevState.name,
args: [
{ numItems, cursor: continueCursor, id: prevState.id },
...prevState.args,
],
};
return {
...prevState,
maxQueryIndex,
queries,
};
});
}
},
} as const;
}, [maybeLastResult]);
return {
results,
...statusAndLoadMore,
};
}
let paginationId = 0;
/**
* Generate a new, unique ID for a pagination session.
*
* Every usage of {@link usePaginatedQueryGeneric} puts a unique ID into the
* query function arguments as a "cache-buster". This serves two purposes:
*
* 1. All calls to {@link usePaginatedQueryGeneric} have independent query
* journals.
*
* Every time we start a new pagination session, we'll load the first page of
* results and receive a fresh journal. Without the ID, we might instead reuse
* a query subscription already present in our client. This isn't desirable
* because the existing query function result may have grown or shrunk from the
* requested `initialNumItems`.
*
* 2. We can restart the pagination session on some types of errors.
*
* Sometimes we want to restart pagination from the beginning if we hit an error.
* Similar to (1), we'd like to ensure that this new session actually requests
* its first page from the server and doesn't reuse a query result already
* present in the client that may have hit the error.
*
* @returns The pagination ID.
*/
function nextPaginationId(): number {
paginationId++;
return paginationId;
}
/**
* The result of calling the {@link usePaginatedQueryGeneric} hook.
*
* This includes:
* 1. `results` - An array of the currently loaded results.
* 2. `status` - The status of the pagination. The possible statuses are:
* - "CanLoadMore": This query may have more items to fetch. Call `loadMore` to
* fetch another page.
* - "LoadingMore": We're currently loading another page of results.
* - "Exhausted": We've paginated to the end of the list.
* 3. `loadMore` A callback to fetch more results. This will be `undefined`
* unless the status is "CanLoadMore".
*
* @public
*/
export type UsePaginatedQueryResult<T> = {
results: T[];
} & (
| {
status: "CanLoadMore";
loadMore: (numItems: number) => void;
}
| {
status: "LoadingMore";
loadMore: undefined;
}
| {
status: "Exhausted";
loadMore: undefined;
}
);
/**
* A query function that is usable with {@link usePaginatedQueryGeneric}.
*
* The function's first argument must be a {@link server.PaginationOptions} object.
* The function must return a {@link server.PaginationResult}.
*
* @public
*/
export type PaginatedQueryFunction<
Args extends Value[],
ReturnType extends Value
> = (
paginationOptions: PaginationOptions,
...args: Args
) => PaginationResult<ReturnType>;
/**
* The names of the paginated query functions in a Convex API.
*
* These are normal query functions that match {@link PaginatedQueryFunction}.
*
* @public
*/
export type PaginatedQueryNames<API extends GenericAPI> = keyof PickByValue<
API["queries"],
PaginatedQueryFunction<any, any>
> &
string;
/**
* The type of the arguments to a {@link PaginatedQueryFunction}.
*
* This type includes all the arguments after the initial
* {@link server.PaginationOptions} argument.
*
* @public
*/
export type PaginatedQueryArgs<Query extends PaginatedQueryFunction<any, any>> =
Query extends PaginatedQueryFunction<infer Args, any> ? Args : never;
/**
* The return type of a {@link PaginatedQueryFunction}.
*
* This is the type of the inner document or object within the
* {@link server.PaginationResult} that a paginated query function returns.
*
* @public
*/
export type PaginatedQueryReturnType<
Query extends PaginatedQueryFunction<any, any>
> = Query extends PaginatedQueryFunction<any, infer ReturnType>
? ReturnType
: never;
/**
* Internal type helper used by Convex code generation.
*
* Used to give {@link usePaginatedQueryGeneric} a type specific to your API.
*
* @public
*/
export type UsePaginatedQueryForAPI<API extends GenericAPI> = <
Name extends PaginatedQueryNames<API>
>(
name: Name,
options: { initialNumItems: number },
...args: PaginatedQueryArgs<NamedQuery<API, Name>>
) => UsePaginatedQueryResult<PaginatedQueryReturnType<NamedQuery<API, Name>>>;
/**
* Optimistically update the values in a paginated list.
*
* This optimistic update is designed to be used to update data loaded with
* {@link usePaginatedQueryGeneric}. It updates the list by applying
* `updateValue` to each element of the list across all of the loaded pages.
*
* This will only apply to queries with a matching names and arguments.
*
* Example usage:
* ```ts
* const myMutation = useMutation("myMutationName")
* .withOptimisticUpdate((localStore, mutationArg) => {
*
* // Optimistically update the document with ID `mutationArg`
* // to have an additional property.
*
* optimisticallyUpdateValueInPaginatedQuery(
* localStore,
* "paginatedQueryName",
* [],
* currentValue => {
* if (mutationArg.equals(currentValue._id)) {
* return {
* ...currentValue,
* "newProperty": "newValue",
* };
* }
* return currentValue;
* }
* );
*
* });
* ```
*
* @param name - The name of the paginated query function.
* @param args - The arguments to the query function, excluding the first.
* @param updateValue - A function to produce the new values.
*
* @public
*/
export function optimisticallyUpdateValueInPaginatedQuery<
API extends GenericAPI,
Name extends PaginatedQueryNames<API>
>(
localStore: OptimisticLocalStore<API>,
name: Name,
args: PaginatedQueryArgs<NamedQuery<API, Name>>,
updateValue: (
currentValue: PaginatedQueryReturnType<NamedQuery<API, Name>>
) => PaginatedQueryReturnType<NamedQuery<API, Name>>
): void {
// TODO(CX-749): This should really be sorted JSON or an `equals` method
// so that the order of properties in sets, maps, and objects doesn't break
// our comparison.
const expectedArgs = JSON.stringify(convexToJson(args));
for (const query of localStore.getAllQueries(name)) {
if (
query.value !== undefined &&
query.args.length >= 1 &&
JSON.stringify(convexToJson(query.args.slice(1))) === expectedArgs
) {
const value = query.value;
if (
typeof value === "object" &&
value !== null &&
Array.isArray(value.page)
) {
localStore.setQuery(name, query.args, {
...value,
page: value.page.map(updateValue),
});
}
}
}
}