Skip to content

Commit

Permalink
Add suspense support as an option to all the react hooks
Browse files Browse the repository at this point in the history
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 `<ClerkProvider/>` 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 `<RequireLogin/>` 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.
  • Loading branch information
airhorns committed Jun 24, 2023
1 parent b2f3106 commit 99c6658
Show file tree
Hide file tree
Showing 20 changed files with 380 additions and 99 deletions.
1 change: 1 addition & 0 deletions packages/api-client-core/src/GadgetConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ export class GadgetConnection {
fetch: this.fetch,
exchanges,
requestPolicy: this.requestPolicy,
suspense: true,
});
(client as any)[$gadgetConnection] = this;
return client;
Expand Down
20 changes: 0 additions & 20 deletions packages/api-client-core/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,26 +419,6 @@ export const traceFunction = <T extends (...args: any[]) => any>(name: string, f

export const getCurrentSpan = () => trace.getSpan(context.active());

interface QueryPlan {
variables: any;
query: string;
}

interface QueryOptions {
context?: Partial<OperationContext>;
pause?: boolean;
requestPolicy?: RequestPolicy;
}

/** Generate `urql` query argument object, for `useQuery` hook */
export const getQueryArgs = <Plan extends QueryPlan, Options extends QueryOptions>(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";

Expand Down
43 changes: 40 additions & 3 deletions packages/blog-example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -42,12 +42,49 @@ function ExampleFindMany() {
);
}

let suspended = false;
function SuspenseFallback() {
suspended = true;
return <div>suspended...</div>;
}
function ExampleSuspense() {
return (
<section className="card">
<h2>Example Suspense</h2>
<p>Ever suspended: {String(suspended)}</p>
<Suspense fallback={<SuspenseFallback />}>
<ExampleSuspenseInner />
</Suspense>
</section>
);
}

function ExampleSuspenseInner() {
const [history, setHistory] = useState<any[]>([]);
const [result, send] = useFindMany(api.post, { suspense: true, sort: { id: "Descending" } });

useEffect(() => {
const { operation, ...keep } = result;
setHistory([...history, keep]);
}, [result]);

return (
<>
<code>
<pre>{JSON.stringify(history, null, 2)}</pre>
</code>
<button onClick={() => send()}>Refetch</button>
</>
);
}

function App() {
return (
<div className="App">
<h1>Vite + Gadget</h1>
<ExampleFetch />
<ExampleFindMany />
{/* <ExampleFetch />
<ExampleFindMany /> */}
<ExampleSuspense />
</div>
);
}
Expand Down
66 changes: 54 additions & 12 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<your-application-slug>
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<Suspense/>`, React's next generation tool for managing asynchrony. Read more about `<Suspense/>` 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 `<Suspense/>` component for rendering a fallback UI while the child component is suspended:
```javascript
<Suspense fallback={"loading..."}>
<Posts/>
</Suspense>
```
With this wrapper in place, the fallback prop will be rendered while the data is being fetched, and once it's available, the `<Posts/>` component will render with data.
Read more about `<Suspense/>` 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:
Expand Down
4 changes: 3 additions & 1 deletion packages/react/spec/testWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const graphqlDocumentName = (doc: DocumentNode) => {
*/
const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => {
const subjects: Record<string, Subject<OperationResult>> = {};
const operations: Record<string, Partial<OperationContext>> = {};

const fn = jest.fn((request: GraphQLRequest, options?: Partial<OperationContext>) => {
const { query } = request;
Expand Down Expand Up @@ -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();
Expand All @@ -143,6 +144,7 @@ export const createMockUrqlCient = (assertions?: {
[$gadgetConnection]: {
fetch: newMockFetchFn(),
},
suspense: true,
} as MockUrqlClient;
};

Expand Down
35 changes: 35 additions & 0 deletions packages/react/spec/useFindBy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,39 @@ 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);
});
});
42 changes: 42 additions & 0 deletions packages/react/spec/useFindMany.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading

0 comments on commit 99c6658

Please sign in to comment.