From 22fe891203f1b979f554980e6332df72fae7e94f Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Sat, 24 Jun 2023 01:04:49 +0000 Subject: [PATCH] Add suspense support as an option to all the react hooks React's new Suspense support is the perfect thing for components declaring that they need something to happen before they can fully render. With all the upcoming auth work that we are doing, I think we're going to have lots of situations on the frontend where we want to know current login state, but where we don't have it yet because its a GraphQL call away. We could solve this by wrapping the whole app in a thing that doesn't render until the session has been fetched. Thats what the `` does. I think we could do the same thing in our provider, but that'd require us assuming that every provider wants a session, which I don't think is a great assumption. Instead, I'd rather have the components that need to know login state block, but the ones that don't ... not! We can do that with suspense. If we add a `` component, it can access session state with a suspense, and then when it arrives, actually decide what to do. It would use the normal, community-agreed-upon mechanism for showing intermediate state spinners or what have you as well. I think our app template should have a base-level suspense that shows a big spinner, but that's nice cause users can customize it or add suspense catchers farther down the tree. So, this adds suspense support to our React hooks! You can see it as an alternative to the `fetching` boolean. Instead of managing the fetching state, you punt it to your parent. See the tests for invocation examples. One annoyance with the way urql's suspense support works is that you have to turn suspense support on at the whole client level, and then individual hooks can choose to enable it or not. By default in urql, when you turn on suspense support in the client, all hooks start using it, and I don't think we want that, we just want folks to be able to use it if they want to. So, there's a bit of args munging to have suspense on at the client level so urql will do it at all, and then we opt-in on a per-hook basis. --- .../api-client-core/src/GadgetConnection.ts | 1 + packages/api-client-core/src/support.ts | 22 +---- packages/blog-example/src/App.tsx | 43 +++++++- packages/react/README.md | 66 ++++++++++--- packages/react/spec/testWrapper.tsx | 4 +- packages/react/spec/useFindBy.spec.ts | 37 +++++++ packages/react/spec/useFindMany.spec.ts | 42 ++++++++ packages/react/spec/useFindOne.spec.ts | 36 +++++++ packages/react/spec/useGet.spec.ts | 31 ++++++ packages/react/src/OptionsType.ts | 6 -- packages/react/src/useAction.ts | 3 +- packages/react/src/useBulkAction.ts | 3 +- packages/react/src/useFindBy.ts | 21 ++-- packages/react/src/useFindFirst.ts | 14 ++- packages/react/src/useFindMany.ts | 14 ++- packages/react/src/useFindOne.ts | 14 ++- packages/react/src/useGet.ts | 9 +- packages/react/src/useMaybeFindFirst.ts | 14 ++- packages/react/src/useMaybeFindOne.ts | 14 ++- packages/react/src/utils.ts | 99 ++++++++++++++++++- 20 files changed, 383 insertions(+), 110 deletions(-) delete mode 100644 packages/react/src/OptionsType.ts 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; +};