UNPKG

convex

Version:

Client for the Convex Cloud

401 lines (380 loc) 12.4 kB
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), }); } } } }