UNPKG

convex-helpers

Version:

A collection of useful code to complement the official convex package.

430 lines (429 loc) 17.2 kB
import { ConvexError } from "convex/values"; import { convexToJson } from "convex/values"; import { useConvex, useQueries, // eslint-disable-next-line @typescript-eslint/no-unused-vars useQuery as useQueryOriginal, } from "convex/react"; import { getFunctionName } from "convex/server"; import { useMemo, useState } from "react"; /** * Use in place of `useQuery` from "convex/react" to fetch data from a query * function but instead returns `{ status, data, error, isSuccess, isPending, isError}`. * * Want a different name? Use `makeUseQueryWithStatus` to create a custom hook: * ```ts * import { useQueries } from "convex/react"; * import { makeUseQueryWithStatus } from "convex-helpers/react"; * export const useQuery = makeUseQueryWithStatus(useQueries); * ``` * * Status is one of "success", "pending", or "error". * Docs copied from {@link useQueryOriginal} until `returns` block: * * Load a reactive query within a React component. * * This React hook contains internal state that will cause a rerender * whenever the query result changes. * * Throws an error if not used under {@link ConvexProvider}. * * @param query - a {@link server.FunctionReference} for the public query to run * like `api.dir1.dir2.filename.func`. * @param args - The arguments to the query function or the string "skip" if the * query should not be loaded. * @returns {status, data, error, isSuccess, isPending, isError} where: * - `status` is one of "success", "pending", or "error" * - `data` is the result of the query function, if it loaded successfully, * - `error` is an `Error` if the query threw an exception. * - `isSuccess` is `true` if the query loaded successfully. * - `isPending` is `true` if the query is still loading or "skip" was passed. * - `isError` is `true` if the query threw an exception. */ export const useQuery = makeUseQueryWithStatus(useQueries); /** * Makes a hook to use in place of `useQuery` from "convex/react" to fetch data from a query * function but instead returns `{ status, data, error, isSuccess, isPending, isError}`. * * You can pass in any hook that matches the signature of {@link useQueries} from "convex/react". * For instance: * * ```ts * import { useQueries } from "convex-helpers/react/cache/hooks"; * import { makeUseQueryWithStatus } from "convex-helpers/react"; * const useQuery = makeUseQueryWithStatus(useQueries); * ``` * * Status is one of "success", "pending", or "error". * Docs copied from {@link useQueryOriginal} until `returns` block: * * Load a reactive query within a React component. * * This React hook contains internal state that will cause a rerender * whenever the query result changes. * * Throws an error if not used under {@link ConvexProvider}. * * @param query - a {@link server.FunctionReference} for the public query to run * like `api.dir1.dir2.filename.func`. * @param args - The arguments to the query function or the string "skip" if the * query should not be loaded. * @returns {status, data, error, isSuccess, isPending, isError} where: * - `status` is one of "success", "pending", or "error" * - `data` is the result of the query function, if it loaded successfully, * - `error` is an `Error` if the query threw an exception. * - `isSuccess` is `true` if the query loaded successfully. * - `isPending` is `true` if the query is still loading or "skip" was passed. * - `isError` is `true` if the query threw an exception. * * @param useQueries Something matching the signature of {@link useQueries} from "convex/react". * @returns * @returns A useQuery function that returns an object with status, data, error, isSuccess, isPending, isError. */ export function makeUseQueryWithStatus(useQueriesHook) { return function useQuery(query, ...queryArgs) { const args = queryArgs[0] ?? {}; const queries = useMemo(() => { if (args === "skip") { return {}; } return { data: { query, args, }, }; }, [getFunctionName(query), JSON.stringify(convexToJson(args))]); const result = useQueriesHook(queries); if (args === "skip") { return { status: "pending", data: undefined, error: undefined, isSuccess: false, isPending: true, isError: false, }; } if (result.data instanceof Error) { return { status: "error", data: undefined, error: result.data, isSuccess: false, isPending: false, isError: true, }; } const { data } = result; if (data === undefined) { return { status: "pending", data, error: undefined, isSuccess: false, isPending: true, isError: false, }; } return { status: "success", data, error: undefined, isSuccess: true, isPending: false, isError: false, }; }; } /** * This is a clone of the `usePaginatedQuery` hook from `convex/react` made for * use with the `stream` and `paginator` helpers, which don't automatically * "grow" until you explicitly pass the `endCursor` arg. * * For these, we wait to set the end cursor until `loadMore` is called. * So the first page will be a fixed size until the first call to `loadMore`, * at which point the second page will start where the first page ended, and the * first page will explicitly "pin" that end cursor. From then on, the last page * will also be a fixed size until the next call to `loadMore`. This is less * noticeable because typically the first page is the only page that grows. * * To use the cached query helpers, you can use those directly and pass * `customPagination: true` in the options. * * Docs copied from {@link usePaginatedQueryOriginal} until `returns` block: * * @param query - a {@link server.FunctionReference} for the public query to run * like `api.dir1.dir2.filename.func`. * @param args - The arguments to the query function or the string "skip" if the * query should not be loaded. */ export function usePaginatedQuery(query, args, options) { if (typeof options?.initialNumItems !== "number" || options.initialNumItems <= 0) { throw new Error(`\`options.initialNumItems\` must be a positive number. Received \`${options?.initialNumItems}\`.`); } const skip = args === "skip"; const argsObject = skip ? {} : args; const queryName = getFunctionName(query); const createInitialState = useMemo(() => { return () => { return { query, args: argsObject, nextPageKey: 1, pageKeys: skip ? [] : [0], queries: skip ? {} : { 0: { query, args: { ...argsObject, paginationOpts: { numItems: options.initialNumItems, cursor: null, }, }, }, }, ongoingSplits: {}, skip, }; }; // 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. }, [ JSON.stringify(convexToJson(argsObject)), queryName, options.initialNumItems, skip, ]); const [state, setState] = useState(createInitialState); // `currState` is the state that we'll render based on. let currState = state; if (skip !== state.skip || getFunctionName(query) !== getFunctionName(state.query) || JSON.stringify(convexToJson(argsObject)) !== JSON.stringify(convexToJson(state.args))) { currState = createInitialState(); setState(currState); } const convexClient = useConvex(); const logger = convexClient.logger; const resultsObject = useQueries(currState.queries); const [results, maybeLastResult] = useMemo(() => { let currResult = undefined; const allItems = []; for (const pageKey of currState.pageKeys) { currResult = resultsObject[pageKey]; if (currResult === undefined) { break; } if (currResult instanceof Error) { if (currResult.message.includes("InvalidCursor") || (currResult instanceof ConvexError && typeof currResult.data === "object" && currResult.data?.isConvexSystemError === true && currResult.data?.paginationError === "InvalidCursor")) { // - 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. // In all cases, we want to restart pagination to throw away all our // existing cursors. logger.warn("usePaginatedQuery hit error, resetting pagination state: " + currResult.message); setState(createInitialState); return [[], undefined]; } else { throw currResult; } } const ongoingSplit = currState.ongoingSplits[pageKey]; if (ongoingSplit !== undefined) { if (resultsObject[ongoingSplit[0]] !== undefined && resultsObject[ongoingSplit[1]] !== undefined) { // Both pages of the split have results now. Swap them in. setState(completeSplitQuery(pageKey)); } } else if (currResult.splitCursor && (currResult.pageStatus === "SplitRecommended" || currResult.pageStatus === "SplitRequired" || // For custom pagination, we eagerly split the page when it grows. currResult.page.length > options.initialNumItems)) { setState(splitQuery(pageKey, currResult.splitCursor, currResult.continueCursor)); } if (currResult.pageStatus === "SplitRequired") { // If pageStatus is 'SplitRequired', it means the server was not able to // fetch the full page. So we stop results before the incomplete // page and return 'LoadingMore' while the page is splitting. return [allItems, undefined]; } allItems.push(...currResult.page); } return [allItems, currResult]; }, [ resultsObject, currState.pageKeys, currState.ongoingSplits, options.initialNumItems, createInitialState, logger, ]); const statusObject = useMemo(() => { if (maybeLastResult === undefined && currState.pageKeys.length <= 1) { return { status: "LoadingFirstPage", isLoading: true, loadMore: (_numItems) => { // Intentional noop. }, }; } else if (maybeLastResult === undefined || // The last page (which isn't the first page) is splitting, which is how // we model loading more in this helper currState.ongoingSplits[currState.pageKeys.at(-1)] !== undefined) { return { status: "LoadingMore", isLoading: true, loadMore: (_numItems) => { // Intentional noop. }, }; } if (maybeLastResult.isDone) { return { status: "Exhausted", isLoading: false, loadMore: (_numItems) => { // Intentional noop. }, }; } const continueCursor = maybeLastResult.continueCursor; let alreadyLoadingMore = false; return { status: "CanLoadMore", isLoading: false, loadMore: (numItems) => { if (!alreadyLoadingMore) { alreadyLoadingMore = true; setState((prevState) => { let nextPageKey = prevState.nextPageKey; const queries = { ...prevState.queries }; let ongoingSplits = prevState.ongoingSplits; // Connect the current last page to the next page // by setting the endCursor of the last page to the continueCursor // of the next page. const lastPageKey = prevState.pageKeys.at(-1); const boundLastPageKey = nextPageKey; queries[boundLastPageKey] = { query: prevState.query, args: { ...prevState.args, paginationOpts: { ...queries[lastPageKey].args .paginationOpts, endCursor: continueCursor, }, }, }; nextPageKey++; ongoingSplits = { ...ongoingSplits, [lastPageKey]: [boundLastPageKey, nextPageKey], }; queries[nextPageKey] = { query: prevState.query, args: { ...prevState.args, paginationOpts: { numItems, cursor: continueCursor, }, }, }; nextPageKey++; return { ...prevState, nextPageKey, queries, ongoingSplits, }; }); } }, }; }, [maybeLastResult, currState.nextPageKey]); return { results, ...statusObject, }; } function splitQuery(key, splitCursor, continueCursor) { return (prevState) => { const queries = { ...prevState.queries }; const splitKey1 = prevState.nextPageKey; const splitKey2 = prevState.nextPageKey + 1; const nextPageKey = prevState.nextPageKey + 2; queries[splitKey1] = { query: prevState.query, args: { ...prevState.args, paginationOpts: { ...prevState.queries[key].args.paginationOpts, endCursor: splitCursor, }, }, }; queries[splitKey2] = { query: prevState.query, args: { ...prevState.args, paginationOpts: { ...prevState.queries[key].args.paginationOpts, cursor: splitCursor, endCursor: continueCursor, }, }, }; const ongoingSplits = { ...prevState.ongoingSplits }; ongoingSplits[key] = [splitKey1, splitKey2]; return { ...prevState, nextPageKey, queries, ongoingSplits, }; }; } function completeSplitQuery(key) { return (prevState) => { const completedSplit = prevState.ongoingSplits[key]; if (completedSplit === undefined) { return prevState; } const queries = { ...prevState.queries }; delete queries[key]; const ongoingSplits = { ...prevState.ongoingSplits }; delete ongoingSplits[key]; let pageKeys = prevState.pageKeys.slice(); const pageIndex = prevState.pageKeys.findIndex((v) => v === key); if (pageIndex >= 0) { pageKeys = [ ...prevState.pageKeys.slice(0, pageIndex), ...completedSplit, ...prevState.pageKeys.slice(pageIndex + 1), ]; } return { ...prevState, queries, pageKeys, ongoingSplits, }; }; }