diff --git a/packages/api-client-core/src/GadgetConnection.ts b/packages/api-client-core/src/GadgetConnection.ts index f74e16a2b..c54de1718 100644 --- a/packages/api-client-core/src/GadgetConnection.ts +++ b/packages/api-client-core/src/GadgetConnection.ts @@ -357,6 +357,7 @@ export class GadgetConnection { fetch: this.fetch, exchanges, requestPolicy: this.requestPolicy, + suspense: true, }); (client as any)[$gadgetConnection] = this; return client; diff --git a/packages/api-client-core/src/support.ts b/packages/api-client-core/src/support.ts index c12e79675..b9e7228c5 100644 --- a/packages/api-client-core/src/support.ts +++ b/packages/api-client-core/src/support.ts @@ -1,6 +1,6 @@ import type { SpanOptions } from "@opentelemetry/api"; import { context, SpanStatusCode, trace } from "@opentelemetry/api"; -import type { OperationContext, OperationResult, RequestPolicy } from "@urql/core"; +import type { OperationResult } from "@urql/core"; import { CombinedError } from "@urql/core"; import { DataHydrator } from "./DataHydrator"; import type { RecordShape } from "./GadgetRecord"; @@ -419,26 +419,6 @@ export const traceFunction = any>(name: string, f export const getCurrentSpan = () => trace.getSpan(context.active()); -interface QueryPlan { - variables: any; - query: string; -} - -interface QueryOptions { - context?: Partial; - pause?: boolean; - requestPolicy?: RequestPolicy; -} - -/** Generate `urql` query argument object, for `useQuery` hook */ -export const getQueryArgs = (plan: Plan, options?: Options) => ({ - query: plan.query, - variables: plan.variables, - context: options?.context, - pause: options?.pause, - requestPolicy: options?.requestPolicy, -}); - // Gadget Storage Test Key that minifies well const key = "gstk"; diff --git a/packages/blog-example/src/App.tsx b/packages/blog-example/src/App.tsx index 53d6a5b55..11cad66d5 100644 --- a/packages/blog-example/src/App.tsx +++ b/packages/blog-example/src/App.tsx @@ -1,5 +1,5 @@ import { useFetch, useFindMany } from "@gadgetinc/react"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { api } from "./api"; import "./styles/App.css"; @@ -42,12 +42,49 @@ function ExampleFindMany() { ); } +let suspended = false; +function SuspenseFallback() { + suspended = true; + return
suspended...
; +} +function ExampleSuspense() { + return ( +
+

Example Suspense

+

Ever suspended: {String(suspended)}

+ }> + + +
+ ); +} + +function ExampleSuspenseInner() { + const [history, setHistory] = useState([]); + const [result, send] = useFindMany(api.post, { suspense: true, sort: { id: "Descending" } }); + + useEffect(() => { + const { operation, ...keep } = result; + setHistory([...history, keep]); + }, [result]); + + return ( + <> + +
{JSON.stringify(history, null, 2)}
+
+ + + ); +} + function App() { return (

Vite + Gadget

- - + {/* + */} +
); } diff --git a/packages/react/README.md b/packages/react/README.md index 814333e13..7b8a8c33a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -151,6 +151,10 @@ There are four different request policies that you can use: For more information on `urql`'s built-in client-side caching, see [`urql`'s docs](https://formidable.com/open-source/urql/docs/basics/document-caching/). + + +`suspense: true` uses `urql`'s Suspense support under the hood. + ### API Documentation `@gadgetinc/react` contains a variety of React hooks for working with your Gadget application's API from a React application. Specific code examples for your application's API can be found within your application's docs at https://docs.gadget.dev/api/ @@ -167,8 +171,9 @@ Your React application must be wrapped in the `Provider` component from this lib - `id`: The backend id of the record you want to find. Required. - `options`: Options for making the call to the backend. Not required, and all keys are optional. - `select`: A list of fields and subfields to select. See the [Select option](#the-select-option) docs. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useFindOne` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a [`refetch` function](#the-refetch-function) to trigger a refresh of the hook's data. @@ -229,8 +234,9 @@ See [the `refetch` function](#the-refetch-function) docs for more information on - `sort`: A sort order to return backend records by. Optional. See the Sorting section in your application's API documentation for more info. - `first` & `after`: Pagination arguments to pass to fetch a subsequent page of records from the backend. `first` should hold a record count and `after` should hold a string cursor retrieved from the `pageInfo` of the previous page of results. See the Model Pagination section in your application's API documentation for more info. - `last` & `before`: Pagination arguments to pass to fetch a subsequent page of records from the backend. `last` should hold a record count and `before` should hold a string cursor retrieved from the `pageInfo` of the previous page of results. See the Model Pagination section in your application's API documentation for more info. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useFindMany` returns two values: a result object with the `data`, `fetching`, and `error` keys for use in your React component's output, and a [`refetch` function](#the-refetch-function) to trigger a refresh of the hook's data. @@ -378,8 +384,9 @@ export const WidgetPaginator = () => { - `filter`: A list of filters to find a record matching. Optional. See the Model Filtering section in your application's API documentation to see the available filters for your models. - `search`: A search string to find a record matching. Optional. See the Model Searching section in your application's API documentation to see the available search syntax. - `sort`: A sort order to order the backend records by. `useFindFirst` will only return the first record matching the given `search` and `filter`, so `sort` can be used to break ties and select a specific record. Optional. See the Sorting section in your application's API documentation for more info. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useFindFirst` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a [`refetch` function](#the-refetch-function) to trigger a refresh of the hook's data. @@ -428,8 +435,9 @@ const [_result, _refetch] = useFindOne(api.widget, props.id, { - `fieldValue`: The value of the field to search for a record using. This is which slug or email you'd pass to `api.widget.findBySlug` or `api.user.findByEmail`. - `options`: Options for making the call to the backend. Not required and all keys are optional. - `select`: A list of fields and subfields to select. See the [Select option](#the-select-option) docs. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useFindBy` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a [`refetch` function](#the-refetch-function) to trigger a refresh of the hook's data. @@ -478,8 +486,9 @@ The `refetch` function returned as the second element can be executed in order t - `actionFunction`: The model action function from your application's API client for acting on records. Gadget generates these action functions for each action defined on backend Gadget models. Required. Example: `api.widget.create`, or `api.user.update` or `api.blogPost.publish`. - `options`: Options for making the call to the backend. Not required and all keys are optional. - `select`: A list of fields and subfields to select. See the [Select option](#the-select-option) docs. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useAction` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a `act` function to actually run the backend action. `useAction` is a rule-following React hook that wraps action execution, which means it doesn't just run the action as soon as the hook is invoked. Instead, `useAction` returns a configured function that will actually run the action, which you need to call in response to some user event. The `act` function accepts the action inputs as arguments -- not `useAction` itself. `useAction`'s result will return the `data`, `fetching`, and `error` details for the most recent execution of the action. @@ -574,7 +583,7 @@ const [{ data, fetching, error }, _refetch] = useFindBy(api.blogPost.findBySlug, - `globalActionFunction`: The action function from your application's API client. Gadget generates these global action functions for each global action defined in your Gadget backend. Required. Example: `api.runSync`, or `api.purgeData` (corresponding to Global Actions named `Run Sync` or `Purge Data`). - `options`: Options for making the call to the backend. Not required and all keys are optional. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) `useGlobalAction` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a `act` function to actually run the backend global action. `useGlobalAction` is a rule-following React hook that wraps action execution, which means it doesn't just run the action as soon as the hook is invoked. Instead, `useGlobalAction` returns a configured function which you need to call in response to some event. This `act` function accepts the action inputs as arguments. `useGlobalAction`'s result will return the `data`, `fetching`, and `error` details for the most recent execution of the action. @@ -622,8 +631,9 @@ export const PurgeData = () => { - `singletonModelManager`: The singleton model manager available on the generated API client for your application. The passed model manager _must_ be one of the `currentSomething` model managers. `useGet` can't be used with other model managers that don't have a `.get` function. Example: `api.currentSession`. - `options`: Options for making the call to the backend. Not required and all keys are optional. - `select`: A list of fields and subfields to select. See the [Select option](#the-select-option) docs. - - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/); + - `requestPolicy`: The `urql` request policy to make the request with. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) - `pause`: Should the hook make a request right now or not. See [`urql`'s docs](https://formidable.com/open-source/urql/docs/api/urql/) + - `suspense`: Should this hook suspend when fetching data. See [Suspense](#suspense) for more info `useGet` returns two values: a result object with the `data`, `fetching`, and `error` keys for inspecting in your React component's output, and a [`refetch` function](#the-refetch-function) to trigger a refresh of the hook's data. @@ -923,17 +933,49 @@ export const ShowWidgetNames = () => { }; ``` +### Suspense + +`@gadgetinc/react` supports two modes for managing loading states: the `fetching` return value, which will be true when making requests under the hood, as well as using ``, React's next generation tool for managing asynchrony. Read more about `` in the [React docs](https://react.dev/reference/react/Suspense). + +To suspend rendering when fetching data, pass the `suspense: true` option to the `useFind*` hooks. + +```javascript +const Posts = () => { + // pass suspense: true, and the component will only render once data has been returned + const [{data, error}, refresh] = useFindMany(api.post, { suspense: true }); + + // note: no need to inspect the fetching prop + return <>{data.map( + //... + )} +} +``` + +All the read hooks support suspense: `useFindOne`, `useMaybeFindOne`, `useFindMany`, `useFindFirst`, `useMaybeFindFirst`, and `useGet`. + +`suspense: true` is most useful when a parent component wraps a suspending-child with the `` component for rendering a fallback UI while the child component is suspended: + +```javascript + + + +``` + +With this wrapper in place, the fallback prop will be rendered while the data is being fetched, and once it's available, the `` component will render with data. + +Read more about `` in the [React docs](https://react.dev/reference/react/Suspense). + ### `urql` exports Since this library uses `urql` behind the scenes, it provides a few useful exports directly from `urql` so that it does not need to be installed as a peer dependency should you need to write custom queries or mutations. The following are exported from `urql`: -- Provider -- Consumer -- Context -- useQuery -- useMutation +- `Provider` +- `Consumer` +- `Context` +- `useQuery` +- `useMutation` Example usage: diff --git a/packages/react/spec/testWrapper.tsx b/packages/react/spec/testWrapper.tsx index 3d45bb1da..d8a5d6e9d 100644 --- a/packages/react/spec/testWrapper.tsx +++ b/packages/react/spec/testWrapper.tsx @@ -51,6 +51,7 @@ export const graphqlDocumentName = (doc: DocumentNode) => { */ const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => { const subjects: Record> = {}; + const operations: Record> = {}; const fn = jest.fn((request: GraphQLRequest, options?: Partial) => { const { query } = request; @@ -122,7 +123,7 @@ const newMockFetchFn = () => { return fn; }; -export const mockUrqlClient = {} as MockUrqlClient; +export const mockUrqlClient = { suspense: true } as MockUrqlClient; beforeEach(() => { mockUrqlClient.executeQuery = newMockOperationFn(); mockUrqlClient.executeMutation = newMockOperationFn(); @@ -143,6 +144,7 @@ export const createMockUrqlCient = (assertions?: { [$gadgetConnection]: { fetch: newMockFetchFn(), }, + suspense: true, } as MockUrqlClient; }; diff --git a/packages/react/spec/useFindBy.spec.ts b/packages/react/spec/useFindBy.spec.ts index 85c842951..86e0bde97 100644 --- a/packages/react/spec/useFindBy.spec.ts +++ b/packages/react/spec/useFindBy.spec.ts @@ -130,4 +130,41 @@ describe("useFindBy", () => { expect(result.current[0].data).toBe(data); }); + + test("it can suspend when finding data", async () => { + const { result, rerender } = renderHook(() => useFindBy(relatedProductsApi.user.findByEmail, "test@test.com", { suspense: true }), { + wrapper: TestWrapper, + }); + + // first render never completes as the component suspends + expect(result.current).toBeFalsy(); + expect(mockUrqlClient.executeQuery).toBeCalledTimes(1); + + mockUrqlClient.executeQuery.pushResponse("users", { + data: { + users: { + edges: [{ cursor: "123", node: { id: "123", email: "test@test.com" } }], + pageInfo: { + startCursor: "123", + endCursor: "123", + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + stale: false, + hasNext: false, + }); + + // rerender as react would do when the suspense promise resolves + rerender(); + expect(result.current).toBeTruthy(); + expect(result.current[0].data!.id).toEqual("123"); + expect(result.current[0].data!.email).toEqual("test@test.com"); + expect(result.current[0].error).toBeFalsy(); + + const beforeObject = result.current[0]; + rerender(); + expect(result.current[0]).toBe(beforeObject); + }); }); diff --git a/packages/react/spec/useFindMany.spec.ts b/packages/react/spec/useFindMany.spec.ts index 2626cbbdd..f34abb184 100644 --- a/packages/react/spec/useFindMany.spec.ts +++ b/packages/react/spec/useFindMany.spec.ts @@ -210,4 +210,46 @@ describe("useFindMany", () => { expect(result.current[0]).toBe(beforeObject); }); + + test("suspends when loading data", async () => { + const { result, rerender } = renderHook(() => useFindMany(relatedProductsApi.user, { suspense: true }), { wrapper: TestWrapper }); + + // first render never completes as the component suspends + expect(result.current).toBeFalsy(); + expect(mockUrqlClient.executeQuery).toBeCalledTimes(1); + + mockUrqlClient.executeQuery.pushResponse("users", { + data: { + users: { + edges: [ + { cursor: "123", node: { id: "123", email: "test@test.com" } }, + { cursor: "abc", node: { id: "124", email: "test@test.com" } }, + ], + pageInfo: { + startCursor: "123", + endCursor: "abc", + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + stale: false, + hasNext: false, + }); + + // rerender as react would do when the suspense promise resolves + rerender(); + expect(result.current).toBeTruthy(); + expect(result.current[0].data![0].id).toEqual("123"); + expect(result.current[0].data![1].id).toEqual("124"); + expect(result.current[0].data!.hasNextPage).toEqual(false); + expect(result.current[0].data!.hasPreviousPage).toEqual(false); + expect(result.current[0].data!.startCursor).toEqual("123"); + expect(result.current[0].data!.endCursor).toEqual("abc"); + expect(result.current[0].error).toBeFalsy(); + + const beforeObject = result.current[0]; + rerender(); + expect(result.current[0]).toBe(beforeObject); + }); }); diff --git a/packages/react/spec/useFindOne.spec.ts b/packages/react/spec/useFindOne.spec.ts index d9a960087..8efe07693 100644 --- a/packages/react/spec/useFindOne.spec.ts +++ b/packages/react/spec/useFindOne.spec.ts @@ -112,4 +112,40 @@ describe("useFindOne", () => { expect(result.current[0]).toBe(beforeObject); }); + + test("suspends when loading data", async () => { + const { result, rerender } = renderHook( + () => { + return useFindOne(relatedProductsApi.user, "123", { suspense: true }); + }, + { wrapper: TestWrapper } + ); + + // first render never completes as the component suspends + expect(result.current).toBeFalsy(); + expect(mockUrqlClient.executeQuery).toBeCalledTimes(1); + + mockUrqlClient.executeQuery.pushResponse("user", { + data: { + user: { + id: "123", + email: "test@test.com", + }, + }, + stale: false, + hasNext: false, + }); + + // rerender as react would do when the suspense promise resolves + rerender(); + expect(result.current).toBeTruthy(); + + expect(result.current[0].data!.id).toEqual("123"); + expect(result.current[0].data!.email).toEqual("test@test.com"); + expect(result.current[0].error).toBeFalsy(); + + const beforeObject = result.current[0]; + rerender(); + expect(result.current[0]).toBe(beforeObject); + }); }); diff --git a/packages/react/spec/useGet.spec.ts b/packages/react/spec/useGet.spec.ts index 691df16ed..e16910c7d 100644 --- a/packages/react/spec/useGet.spec.ts +++ b/packages/react/spec/useGet.spec.ts @@ -102,4 +102,35 @@ describe("useGet", () => { expect(result.current[0]).toBe(beforeObject); }); + + test("it can find the current session with suspense", async () => { + const { result, rerender } = renderHook(() => useGet(relatedProductsApi.currentSession, { suspense: true }), { wrapper: TestWrapper }); + + // first render never completes as the component suspends + expect(result.current).toBeFalsy(); + expect(mockUrqlClient.executeQuery).toBeCalledTimes(1); + + mockUrqlClient.executeQuery.pushResponse("currentSession", { + data: { + currentSession: { + id: "123", + }, + }, + stale: false, + hasNext: false, + }); + + // rerender as react would do when the suspense promise resolves + rerender(); + expect(result.current).toBeTruthy(); + + expect(result.current[0].data!.id).toEqual("123"); + expect(result.current[0].error).toBeFalsy(); + + const beforeObject = result.current[0]; + + rerender(); + + expect(result.current[0]).toBe(beforeObject); + }); }); diff --git a/packages/react/src/OptionsType.ts b/packages/react/src/OptionsType.ts deleted file mode 100644 index 1bbe2c4f8..000000000 --- a/packages/react/src/OptionsType.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { FieldSelection } from "@gadgetinc/api-client-core"; - -export type OptionsType = { - [key: string]: any; - select?: FieldSelection; -}; diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts index d7ddbabf9..9a29bf257 100644 --- a/packages/react/src/useAction.ts +++ b/packages/react/src/useAction.ts @@ -3,10 +3,9 @@ import { actionOperation, capitalizeIdentifier, get, hydrateRecord } from "@gadg import { useCallback, useContext, useMemo } from "react"; import type { AnyVariables, UseMutationState } from "urql"; import { GadgetUrqlClientContext } from "./GadgetProvider"; -import type { OptionsType } from "./OptionsType"; import { useGadgetMutation } from "./useGadgetMutation"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ActionHookResult, ActionHookState } from "./utils"; +import type { ActionHookResult, ActionHookState, OptionsType } from "./utils"; import { ErrorWrapper, noProviderErrorMessage } from "./utils"; /** diff --git a/packages/react/src/useBulkAction.ts b/packages/react/src/useBulkAction.ts index bb52decc0..0aacca185 100644 --- a/packages/react/src/useBulkAction.ts +++ b/packages/react/src/useBulkAction.ts @@ -2,10 +2,9 @@ import type { BulkActionFunction, DefaultSelection, GadgetRecord, LimitToKnownKe import { actionOperation, capitalizeIdentifier, get, hydrateRecordArray } from "@gadgetinc/api-client-core"; import { useCallback, useMemo } from "react"; import type { UseMutationState } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetMutation } from "./useGadgetMutation"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ActionHookResult } from "./utils"; +import type { ActionHookResult, OptionsType } from "./utils"; import { ErrorWrapper } from "./utils"; /** diff --git a/packages/react/src/useFindBy.ts b/packages/react/src/useFindBy.ts index b1ae09421..449bfd94e 100644 --- a/packages/react/src/useFindBy.ts +++ b/packages/react/src/useFindBy.ts @@ -1,19 +1,10 @@ import type { DefaultSelection, FindOneFunction, GadgetRecord, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { - GadgetNotFoundError, - findOneByFieldOperation, - get, - getNonUniqueDataError, - getQueryArgs, - hydrateConnection, -} from "@gadgetinc/api-client-core"; +import { GadgetNotFoundError, findOneByFieldOperation, get, getNonUniqueDataError, hydrateConnection } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { OptionsType, ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch a Gadget record using the `findByXYZ` method of a given model manager. Useful for finding records by key fields which are used for looking up records by. Gadget autogenerates the `findByXYZ` methods on your model managers, and `useFindBy` can only be used with models that have these generated finder functions. @@ -42,11 +33,11 @@ export const useFindBy = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindOneFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( finder: F, value: string, - options?: LimitToKnownKeys & Omit + options?: LimitToKnownKeys ): ReadHookResult< GadgetRecord, DefaultSelection>> > => { @@ -62,7 +53,7 @@ export const useFindBy = < ); }, [finder, value, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, options)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, options)); const result = useMemo(() => { const dataPath = [finder.operationName]; diff --git a/packages/react/src/useFindFirst.ts b/packages/react/src/useFindFirst.ts index 048056d62..74d8881fb 100644 --- a/packages/react/src/useFindFirst.ts +++ b/packages/react/src/useFindFirst.ts @@ -1,12 +1,10 @@ import type { DefaultSelection, FindFirstFunction, GadgetRecord, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { findManyOperation, get, getQueryArgs, hydrateConnection } from "@gadgetinc/api-client-core"; +import { findManyOperation, get, hydrateConnection } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { OptionsType, ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch the first backend record matching a given filter and sort. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the first record found if there is one, and null otherwise. @@ -36,10 +34,10 @@ export const useFindFirst = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindFirstFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( manager: { findFirst: F }, - options?: LimitToKnownKeys & Omit + options?: LimitToKnownKeys ): ReadHookResult< GadgetRecord, DefaultSelection>> > => { @@ -54,7 +52,7 @@ export const useFindFirst = < ); }, [manager, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, firstOptions)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, firstOptions)); const result = useMemo(() => { const dataPath = [manager.findFirst.operationName]; diff --git a/packages/react/src/useFindMany.ts b/packages/react/src/useFindMany.ts index b5148b2aa..14f0de2f0 100644 --- a/packages/react/src/useFindMany.ts +++ b/packages/react/src/useFindMany.ts @@ -1,12 +1,10 @@ import type { AnyModelManager, DefaultSelection, FindManyFunction, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { GadgetRecordList, findManyOperation, get, getQueryArgs, hydrateConnection } from "@gadgetinc/api-client-core"; +import { GadgetRecordList, findManyOperation, get, hydrateConnection } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, OptionsType, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch a page of Gadget records from the backend, optionally sorted, filtered, searched, and selected from. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be a `GadgetRecordList` object holding the list of returned records and pagination info. @@ -36,10 +34,10 @@ export const useFindMany = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindManyFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( manager: { findMany: F }, - options?: LimitToKnownKeys & Omit + options?: LimitToKnownKeys ): ReadHookResult< GadgetRecordList, DefaultSelection>> > => { @@ -53,7 +51,7 @@ export const useFindMany = < ); }, [manager, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, options)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, options)); const result = useMemo(() => { const dataPath = [manager.findMany.operationName]; diff --git a/packages/react/src/useFindOne.ts b/packages/react/src/useFindOne.ts index dd1bdc192..f843836b0 100644 --- a/packages/react/src/useFindOne.ts +++ b/packages/react/src/useFindOne.ts @@ -1,12 +1,10 @@ import type { DefaultSelection, FindOneFunction, GadgetRecord, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { findOneOperation, get, getQueryArgs, hydrateRecord } from "@gadgetinc/api-client-core"; +import { findOneOperation, get, hydrateRecord } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { OptionsType, ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch one Gadget record by `id` from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the record if it was found, and `null` otherwise. @@ -36,11 +34,11 @@ export const useFindOne = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindOneFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( manager: { findOne: F }, id: string, - options?: LimitToKnownKeys> + options?: LimitToKnownKeys ): ReadHookResult< GadgetRecord, DefaultSelection>> > => { @@ -55,7 +53,7 @@ export const useFindOne = < ); }, [manager, id, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, options)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, options)); const result = useMemo(() => { const dataPath = [manager.findOne.operationName]; diff --git a/packages/react/src/useGet.ts b/packages/react/src/useGet.ts index 273b63554..f9e321630 100644 --- a/packages/react/src/useGet.ts +++ b/packages/react/src/useGet.ts @@ -1,11 +1,10 @@ import type { DefaultSelection, GadgetRecord, GetFunction, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; import { findOneOperation, get, hydrateRecord } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import { ErrorWrapper, OptionsType, ReadOperationOptions, useMemoizedQueryArgs } from "./utils"; /** * React hook that fetches a singleton record for an `api.currentSomething` style model manager. `useGet` fetches one global record, which is most often the current session. `useGet` doesn't require knowing the record's ID in order to fetch it, and instead returns the one current record for the current context. @@ -35,10 +34,10 @@ export const useGet = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends GetFunction, - Options extends F["optionsType"] + Options extends F["optionsType"] & ReadOperationOptions >( manager: { get: F }, - options?: LimitToKnownKeys + options?: LimitToKnownKeys ): ReadHookResult< GadgetRecord, DefaultSelection>> > => { @@ -53,7 +52,7 @@ export const useGet = < ); }, [manager, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery({ query: plan.query, variables: plan.variables }); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, options)); const result = useMemo(() => { let data = null; diff --git a/packages/react/src/useMaybeFindFirst.ts b/packages/react/src/useMaybeFindFirst.ts index 0f3557959..54c37d1fe 100644 --- a/packages/react/src/useMaybeFindFirst.ts +++ b/packages/react/src/useMaybeFindFirst.ts @@ -1,12 +1,10 @@ import type { DefaultSelection, FindFirstFunction, GadgetRecord, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { findManyOperation, get, getQueryArgs, hydrateConnection } from "@gadgetinc/api-client-core"; +import { findManyOperation, get, hydrateConnection } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { OptionsType, ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch many Gadget records using the `maybeFindFirst` method of a given manager. @@ -36,10 +34,10 @@ export const useMaybeFindFirst = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindFirstFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( manager: { findFirst: F }, - options?: LimitToKnownKeys & Omit + options?: LimitToKnownKeys ): ReadHookResult, DefaultSelection> >> => { @@ -54,7 +52,7 @@ export const useMaybeFindFirst = < ); }, [manager, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, firstOptions)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, firstOptions)); const result = useMemo(() => { const dataPath = [manager.findFirst.operationName]; diff --git a/packages/react/src/useMaybeFindOne.ts b/packages/react/src/useMaybeFindOne.ts index f1c91cdc4..8bd857032 100644 --- a/packages/react/src/useMaybeFindOne.ts +++ b/packages/react/src/useMaybeFindOne.ts @@ -1,12 +1,10 @@ import type { DefaultSelection, FindOneFunction, GadgetRecord, LimitToKnownKeys, Select } from "@gadgetinc/api-client-core"; -import { findOneOperation, get, getQueryArgs, hydrateRecord } from "@gadgetinc/api-client-core"; +import { findOneOperation, get, hydrateRecord } from "@gadgetinc/api-client-core"; import { useMemo } from "react"; -import type { UseQueryArgs } from "urql"; -import type { OptionsType } from "./OptionsType"; import { useGadgetQuery } from "./useGadgetQuery"; import { useStructuralMemo } from "./useStructuralMemo"; -import type { ReadHookResult } from "./utils"; -import { ErrorWrapper } from "./utils"; +import type { OptionsType, ReadHookResult, ReadOperationOptions } from "./utils"; +import { ErrorWrapper, useMemoizedQueryArgs } from "./utils"; /** * React hook to fetch a Gadget record using the `maybeFindOne` method of a given manager. @@ -36,11 +34,11 @@ export const useMaybeFindOne = < GivenOptions extends OptionsType, // currently necessary for Options to be a narrow type (e.g., `true` instead of `boolean`) SchemaT, F extends FindOneFunction, - Options extends F["optionsType"] & Omit + Options extends F["optionsType"] & ReadOperationOptions >( manager: { findOne: F }, id: string, - options?: LimitToKnownKeys> + options?: LimitToKnownKeys ): ReadHookResult, DefaultSelection> >> => { @@ -55,7 +53,7 @@ export const useMaybeFindOne = < ); }, [manager, id, memoizedOptions]); - const [rawResult, refresh] = useGadgetQuery(getQueryArgs(plan, options)); + const [rawResult, refresh] = useGadgetQuery(useMemoizedQueryArgs(plan, options)); const result = useMemo(() => { let data = rawResult.data ?? null; diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 05c53074f..ad9adfbb1 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -1,8 +1,66 @@ import type { GadgetError, InvalidFieldError, InvalidRecordError } from "@gadgetinc/api-client-core"; -import { gadgetErrorFor, getNonNullableError } from "@gadgetinc/api-client-core"; -import type { CombinedError } from "@urql/core"; +import { FieldSelection, gadgetErrorFor, getNonNullableError } from "@gadgetinc/api-client-core"; +import type { CombinedError, RequestPolicy } from "@urql/core"; import { GraphQLError } from "graphql"; -import type { AnyVariables, Operation, OperationContext, UseQueryState } from "urql"; +import { useMemo } from "react"; +import type { AnyVariables, Operation, OperationContext, UseQueryArgs, UseQueryState } from "urql"; + +/** + * All the options controlling how this query will be managed by urql + * */ +export declare type ReadOperationOptions = { + /** Updates the {@link RequestPolicy} for the executed GraphQL query operation. + * + * @remarks + * `requestPolicy` modifies the {@link RequestPolicy} of the GraphQL query operation + * that `useQuery` executes, and indicates a caching strategy for cache exchanges. + * + * For example, when set to `'cache-and-network'`, {@link useQuery} will + * receive a cached result with `stale: true` and an API request will be + * sent in the background. + * + * @see {@link OperationContext.requestPolicy} for where this value is set. + */ + requestPolicy?: RequestPolicy; + /** Updates the {@link OperationContext} for the executed GraphQL query operation. + * + * @remarks + * `context` may be passed to {@link useQuery}, to update the {@link OperationContext} + * of a query operation. This may be used to update the `context` that exchanges + * will receive for a single hook. + * + * Hint: This should be wrapped in a `useMemo` hook, to make sure that your + * component doesn’t infinitely update. + * + * @example + * ```ts + * const [result, reexecute] = useQuery({ + * query, + * context: useMemo(() => ({ + * additionalTypenames: ['Item'], + * }), []) + * }); + * ``` + */ + context?: Partial; + /** Prevents {@link useQuery} from automatically executing GraphQL query operations. + * + * @remarks + * `pause` may be set to `true` to stop {@link useQuery} from executing + * automatically. The hook will stop receiving updates from the {@link Client} + * and won’t execute the query operation, until either it’s set to `false` + * or the {@link UseQueryExecute} function is called. + * + * @see {@link https://urql.dev/goto/docs/basics/react-preact/#pausing-usequery} for + * documentation on the `pause` option. + */ + pause?: boolean; + /** + * Marks this query as one that should suspend the react component rendering while executing, instead of returning `{fetching: true}` to the caller. + * Useful if you want to allow components higher in the tree to show spinners instead of having every component manage its own loading state. + */ + suspense?: boolean; +}; /** * The inner result object returned from a query result @@ -182,3 +240,38 @@ export class ErrorWrapper extends Error { return firstInvalidRecordError?.validationErrors ?? null; } } + +interface QueryPlan { + variables: any; + query: string; +} + +interface QueryOptions { + context?: Partial; + pause?: boolean; + requestPolicy?: RequestPolicy; + suspense?: boolean; +} + +/** Generate `urql` query argument object, for `useQuery` hook */ +export const useMemoizedQueryArgs = (plan: Plan, options?: Options): UseQueryArgs => { + // use a memo as urql rerenders on context identity changes + const context = useMemo(() => { + return { + suspense: !!options?.suspense, + ...options?.context, + }; + }, [options?.suspense, options?.context]); + + return { + query: plan.query, + variables: plan.variables, + ...options, + context, + }; +}; + +export type OptionsType = { + [key: string]: any; + select?: FieldSelection; +};