diff --git a/npm-packages/convex/src/browser/query_options.ts b/npm-packages/convex/src/browser/query_options.ts index caae1c0ca..7ac46041e 100644 --- a/npm-packages/convex/src/browser/query_options.ts +++ b/npm-packages/convex/src/browser/query_options.ts @@ -5,22 +5,23 @@ */ import type { FunctionArgs, FunctionReference } from "../server/api.js"; -// TODO if this type can encompass all use cases we can add not requiring args for queries -// that don't take arguments. Goal would be that queryOptions allows leaving out args, -// but queryOptions returns an object that always contains args. Helpers, "middleware," -// anything that intercepts these arguments /** * Query options. */ -export type ConvexQueryOptions> = { +export type QueryOptions> = { + /** + * The query function to run. + */ query: Query; + /** + * The arguments to the query function. + */ args: FunctionArgs; - extendSubscriptionFor?: number; }; // This helper helps more once we have more inference happening. export function convexQueryOptions>( - options: ConvexQueryOptions, -): ConvexQueryOptions { + options: QueryOptions, +) { return options; } diff --git a/npm-packages/convex/src/react/client.ts b/npm-packages/convex/src/react/client.ts index 38e2be56a..12e8b2b83 100644 --- a/npm-packages/convex/src/react/client.ts +++ b/npm-packages/convex/src/react/client.ts @@ -32,12 +32,14 @@ import { instantiateNoopLogger, Logger, } from "../browser/logging.js"; -import { ConvexQueryOptions } from "../browser/query_options.js"; import { LoadMoreOfPaginatedQuery } from "../browser/sync/pagination.js"; import { PaginatedQueryClient, ExtendedTransition, } from "../browser/sync/paginated_query_client.js"; +import type { QueryOptions } from "../browser/query_options.js"; +import type { Preloaded } from "./hydration.js"; +import { parsePreloaded } from "./preloaded.js"; // When no arguments are passed, extend subscriptions (for APIs that do this by default) // for this amount after the subscription would otherwise be dropped. @@ -537,7 +539,7 @@ export class ConvexReactClient { * an optional extendSubscriptionFor for how long to subscribe to the query. */ prewarmQuery>( - queryOptions: ConvexQueryOptions & { + queryOptions: QueryOptions & { extendSubscriptionFor?: number; }, ) { @@ -801,6 +803,67 @@ export type OptionalRestArgsOrSkip> = ? [args?: EmptyObject | "skip"] : [args: FuncRef["_args"] | "skip"]; +/** + * Options for the object-based {@link useQuery} overload. + * + * @public + */ +export type UseQueryOptions> = + QueryOptions & { + /** + * Whether to throw an error if the query fails. + * If false, the error will be returned in the `error` field. + * @defaultValue false + */ + throwOnError?: boolean; + /** + * An initial value to use before the query result is available. + * @defaultValue undefined + */ + initialValue?: Query["_returnType"]; + }; + +/** + * Options for the object-based {@link useQuery} overload with a preloaded query. + * + * @public + */ +export type UseQueryPreloadedOptions> = + { + /** + * A preloaded query result from a Server Component. + */ + preloaded: Preloaded; + /** + * Whether to throw an error if the query fails. + * If false, the error will be returned in the `error` field. + * @defaultValue false + */ + throwOnError?: boolean; + }; + +/** + * Result type for the object-based {@link useQuery} overload. + * + * @public + */ +export type UseQueryResult = + | { + status: "success"; + data: T; + error: undefined; + } + | { + status: "error"; + data: undefined; + error: Error; + } + | { + status: "loading"; + data: undefined; + error: undefined; + }; + /** * Load a reactive query within a React component. * @@ -820,20 +883,83 @@ export type OptionalRestArgsOrSkip> = export function useQuery>( query: Query, ...args: OptionalRestArgsOrSkip -): Query["_returnType"] | undefined { - const skip = args[0] === "skip"; - const argsObject = args[0] === "skip" ? {} : parseArgs(args[0]); +): Query["_returnType"] | undefined; + +/** + * Load a reactive query within a React component using an options object. + * + * This overload returns an object with `status`, `error`, and `value` fields + * instead of throwing errors or returning undefined. + * + * 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 options - An options object or the string "skip" to skip the query. + * @returns An object with `status`, `error`, and `value` fields. + * + * @public + */ +export function useQuery>( + options: UseQueryOptions | UseQueryPreloadedOptions | "skip", +): UseQueryResult; - const queryReference = - typeof query === "string" - ? makeFunctionReference<"query", any, any>(query) - : query; +export function useQuery>( + queryOrOptions: + | Query + | UseQueryOptions + | UseQueryPreloadedOptions + | "skip", + ...args: OptionalRestArgsOrSkip +): Query["_returnType"] | undefined | UseQueryResult { + const isObjectOptions = + typeof queryOrOptions === "object" && + queryOrOptions !== null && + ("query" in queryOrOptions || "preloaded" in queryOrOptions); + const isObjectSkip = queryOrOptions === "skip"; + const isLegacy = !isObjectOptions && !isObjectSkip; + const legacySkip = isLegacy && args[0] === "skip"; + const isObjectReturn = isObjectOptions || isObjectSkip; + + let queryReference: Query | undefined; + let argsObject: Record = {}; + let throwOnError = false; + let initialValue: Query["_returnType"] | undefined; + let preloadedResult: Query["_returnType"] | undefined; + + if (isObjectOptions) { + if ("preloaded" in queryOrOptions) { + const parsed = parsePreloaded(queryOrOptions.preloaded); + queryReference = parsed.queryReference; + argsObject = parsed.argsObject; + preloadedResult = parsed.preloadedResult; + throwOnError = queryOrOptions.throwOnError ?? false; + } else { + const query = queryOrOptions.query; + queryReference = + typeof query === "string" + ? (makeFunctionReference<"query", any, any>(query) as Query) + : query; + argsObject = queryOrOptions.args ?? ({} as Record); + throwOnError = queryOrOptions.throwOnError ?? false; + initialValue = queryOrOptions.initialValue; + } + } else if (isLegacy) { + const query = queryOrOptions as Query; + queryReference = + typeof query === "string" + ? (makeFunctionReference<"query", any, any>(query) as Query) + : query; + argsObject = legacySkip ? {} : parseArgs(args[0] as Query["_args"]); + } - const queryName = getFunctionName(queryReference); + const skip = isObjectSkip || legacySkip; + const queryName = queryReference ? getFunctionName(queryReference) : ""; const queries = useMemo( () => - skip + skip || !queryReference ? ({} as RequestForQueries) : { query: { query: queryReference, args: argsObject } }, // Stringify args so args that are semantically the same don't trigger a @@ -844,10 +970,46 @@ export function useQuery>( const results = useQueries(queries); const result = results["query"]; + + if (!isObjectReturn) { + if (result instanceof Error) { + throw result; + } + return result; + } + if (result instanceof Error) { - throw result; + if (throwOnError) { + throw result; + } + return { + status: "error", + data: undefined, + error: result, + } satisfies UseQueryResult; + } + + if (result === undefined) { + const fallbackValue = preloadedResult ?? initialValue; + if (fallbackValue !== undefined) { + return { + status: "success", + data: fallbackValue, + error: undefined, + } satisfies UseQueryResult; + } + return { + status: "loading", + data: undefined, + error: undefined, + } satisfies UseQueryResult; } - return result; + + return { + status: "success", + data: result, + error: undefined, + } satisfies UseQueryResult; } /** diff --git a/npm-packages/convex/src/react/hydration.tsx b/npm-packages/convex/src/react/hydration.tsx index f20895825..abd41b4a0 100644 --- a/npm-packages/convex/src/react/hydration.tsx +++ b/npm-packages/convex/src/react/hydration.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "../react/client.js"; -import { FunctionReference, makeFunctionReference } from "../server/api.js"; -import { jsonToConvex } from "../values/index.js"; +import { FunctionReference } from "../server/api.js"; +import { parsePreloaded } from "./preloaded.js"; /** * The preloaded query payload, which should be passed to a client component @@ -34,17 +34,10 @@ export type Preloaded> = { export function usePreloadedQuery>( preloadedQuery: Preloaded, ): Query["_returnType"] { - const args = useMemo( - () => jsonToConvex(preloadedQuery._argsJSON), - [preloadedQuery._argsJSON], - ) as Query["_args"]; - const preloadedResult = useMemo( - () => jsonToConvex(preloadedQuery._valueJSON), - [preloadedQuery._valueJSON], + const parsed = useMemo( + () => parsePreloaded(preloadedQuery), + [preloadedQuery], ); - const result = useQuery( - makeFunctionReference(preloadedQuery._name) as Query, - args, - ); - return result === undefined ? preloadedResult : result; + const result = useQuery(parsed.queryReference, parsed.argsObject); + return result === undefined ? parsed.preloadedResult : result; } diff --git a/npm-packages/convex/src/react/index.ts b/npm-packages/convex/src/react/index.ts index f7d08f3f0..bd879ad4f 100644 --- a/npm-packages/convex/src/react/index.ts +++ b/npm-packages/convex/src/react/index.ts @@ -78,6 +78,7 @@ export { type MutationOptions, type ConvexReactClientOptions, type OptionalRestArgsOrSkip, + type UseQueryResult, ConvexReactClient, useConvex, ConvexProvider, diff --git a/npm-packages/convex/src/react/preloaded.ts b/npm-packages/convex/src/react/preloaded.ts new file mode 100644 index 000000000..b851149d5 --- /dev/null +++ b/npm-packages/convex/src/react/preloaded.ts @@ -0,0 +1,29 @@ +import { + FunctionArgs, + FunctionReference, + makeFunctionReference, +} from "../server/api.js"; +import { jsonToConvex } from "../values/index.js"; +import type { Preloaded } from "./hydration.js"; + +/** + * Parse a preloaded query payload into its constituent parts. + * + * This is a hook-free helper that can be used by both `useQuery` and + * `usePreloadedQuery` to avoid duplicating the parsing logic. + * + * @internal + */ +export function parsePreloaded>( + preloaded: Preloaded, +): { + queryReference: Query; + argsObject: FunctionArgs; + preloadedResult: Query["_returnType"]; +} { + return { + queryReference: makeFunctionReference(preloaded._name) as Query, + argsObject: jsonToConvex(preloaded._argsJSON) as FunctionArgs, + preloadedResult: jsonToConvex(preloaded._valueJSON) as Query["_returnType"], + }; +} diff --git a/npm-packages/convex/src/react/use_query.test.ts b/npm-packages/convex/src/react/use_query.test.ts index 543a777d8..503dee2fb 100644 --- a/npm-packages/convex/src/react/use_query.test.ts +++ b/npm-packages/convex/src/react/use_query.test.ts @@ -3,11 +3,12 @@ */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { test, describe, expect } from "vitest"; +import { test, describe, expect, expectTypeOf } from "vitest"; import { anyApi } from "../server/api.js"; -import { ApiFromModules, QueryBuilder } from "../server/index.js"; +import type { ApiFromModules, QueryBuilder } from "../server/index.js"; import { useQuery as useQueryReal } from "./client.js"; +import type { Preloaded } from "./hydration.js"; // Intentional noop, we're just testing types. const useQuery = (() => {}) as unknown as typeof useQueryReal; @@ -68,4 +69,76 @@ describe("useQuery types", () => { // @ts-expect-error adding args is not allowed useQuery(api.module.noArgs, { _arg: 1 }); }); + + test("Queries with object options", () => { + useQuery({ + query: api.module.noArgs, + args: {}, + }); + + useQuery({ + query: api.module.noArgs, + args: {}, + }); + + useQuery({ + query: api.module.args, + args: { _arg: "asdf" }, + }); + + useQuery({ + query: api.module.args, + args: { _arg: "asdf" }, + initialValue: "initial value", + }); + + useQuery({ + query: api.module.args, + args: { _arg: "asdf" }, + throwOnError: true, + }); + + const _arg: string | undefined = undefined; + + useQuery( + !_arg + ? "skip" + : { + query: api.module.args, + args: { _arg }, + }, + ); + + const { + status: _status, + data: _data, + error: _error, + } = useQuery({ + query: api.module.args, + args: { _arg: "asdf" }, + initialValue: "initial value", + throwOnError: true, + }); + if (_status === "success") { + expectTypeOf(_data).toEqualTypeOf("initial value"); + } + if (_status === "error") { + expectTypeOf(_error).toEqualTypeOf(); + } + if (_status === "loading") { + expectTypeOf(_data).toEqualTypeOf(); + } + + useQuery("skip"); + }); + + test("Queries with preloaded options", () => { + const { + status: _status, + data: _data, + error: _error, + } = useQuery({ + preloaded: {} as Preloaded, + }); + }); });