Skip to content

Commit

Permalink
Merge pull request #215 from gadget-inc/urql-suspense
Browse files Browse the repository at this point in the history
Suspense support for React hooks
  • Loading branch information
airhorns authored Jun 30, 2023
2 parents b97b7f2 + 29f26ed commit d59d082
Show file tree
Hide file tree
Showing 20 changed files with 381 additions and 108 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 @@ -358,6 +358,7 @@ export class GadgetConnection {
fetch: this.fetch,
exchanges,
requestPolicy: this.requestPolicy,
suspense: true,
});
(client as any)[$gadgetConnection] = this;
return client;
Expand Down
22 changes: 1 addition & 21 deletions packages/api-client-core/src/support.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down 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
39 changes: 38 additions & 1 deletion 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 />
<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
37 changes: 37 additions & 0 deletions packages/react/spec/useFindBy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
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 d59d082

Please sign in to comment.