Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export default defineConfig({
text: 'useQuery',
link: '/use-query'
},

{
text: 'useSuspenseQuery',
link: '/use-suspense-query'
},
]
},
],
Expand Down
5 changes: 4 additions & 1 deletion docs/api/hook-builders.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: Hook Builders

# {{ $frontmatter.title }}

Hook builders initialize `useQuery`, `useImmutate`, `useInfinite`, and `useMutate`.
Hook builders initialize `useQuery`, `useImmutate`, `useInfinite`, `useMutate`, and `useSuspenseQuery`.

Each builder function accepts an instance of a [fetch client](https://openapi-ts.dev/openapi-fetch/api) and a prefix unique to that client.

Expand All @@ -24,6 +24,7 @@ import {
createImmutableHook,
createInfiniteHook,
createMutateHook,
createSuspenseQueryHook,
} from "swr-openapi";

import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
Expand All @@ -34,6 +35,7 @@ const prefix = "my-api";
export const useQuery = createQueryHook(client, prefix);
export const useImmutable = createImmutableHook(client, prefix);
export const useInfinite = createInfiniteHook(client, prefix);
export const useSuspenseQuery = createSuspenseQueryHook(client, prefix);
export const useMutate = createMutateHook(
client,
prefix,
Expand All @@ -59,6 +61,7 @@ Each builder hook accepts the same initial parameters:
- `createQueryHook` → [`useQuery`](./use-query.md)
- `createImmutableHook` → [`useImmutable`](./use-immutable.md)
- `createInfiniteHook` → [`useInfinite`](./use-infinite.md)
- `createSuspenseQueryHook` → [`useSuspenseQuery`](./use-suspense-query.md)
- `createMutateHook` → [`useMutate`](./use-mutate.md)

## `compare`
Expand Down
82 changes: 82 additions & 0 deletions docs/api/use-suspense-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: useSuspenseQuery
---

# {{ $frontmatter.title }}

This hook is a typed wrapper over [`useSWR`][swr-api] with `suspense: true` enabled.

The key difference from `useQuery` is that `data` is always defined (non-optional), since React Suspense guarantees data is available before the component renders.

```ts
import createClient from "openapi-fetch";
import { createSuspenseQueryHook } from "swr-openapi";
import type { paths } from "./my-schema";

const client = createClient<paths>(/* ... */);

const useSuspenseQuery = createSuspenseQueryHook(client, "my-api");

// data is always defined (not data | undefined)
const { data, error, isValidating, mutate } = useSuspenseQuery(
path,
init,
config,
);
```

## API

### Parameters

- `path`: Any endpoint that supports `GET` requests.
- `init`: (_sometimes optional_)
- [Fetch options][oai-fetch-options] for the chosen endpoint.
- `null` to skip the request (see [SWR Conditional Fetching][swr-conditional-fetching]).
- `config`: (_optional_) [SWR options][swr-options] (without `suspense`, which is always enabled).

### Returns

- An [SWR response][swr-response] where `data` is always defined (`Data` instead of `Data | undefined`).

## How It Works

`useSuspenseQuery` is nearly identical to [`useQuery`](./use-query.md), with two key differences:

1. The `suspense: true` option is always passed to SWR
2. The return type guarantees `data` is defined

```ts
function useSuspenseQuery(path, ...[init, config]) {
return useSWR(
init !== null ? [prefix, path, init] : null,
async ([_prefix, path, init]) => {
const res = await client.GET(path, init);
if (res.error) {
throw res.error;
}
return res.data;
},
{ ...config, suspense: true },
);
}
```

::: tip

When using suspense, wrap your component with a `<Suspense>` boundary to handle the loading state:

```tsx
<Suspense fallback={<Loading />}>
<MyComponent />
</Suspense>
```

:::


[oai-fetch-options]: https://openapi-ts.pages.dev/openapi-fetch/api#fetch-options
[swr-options]: https://swr.vercel.app/docs/api#options
[swr-conditional-fetching]: https://swr.vercel.app/docs/conditional-fetching#conditional
[swr-response]: https://swr.vercel.app/docs/api#return-values
[swr-api]: https://swr.vercel.app/docs/api
17 changes: 17 additions & 0 deletions src/__test__/suspense.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import useSWR from "swr";
import { describe, expect, it, vi } from "vitest";
import * as SuspenseBase from "../suspense-base.js";

vi.mock("../suspense-base.js");
const { configureBaseSuspenseQueryHook } = vi.mocked(SuspenseBase);
// @ts-expect-error - return type is not relevant to this test
configureBaseSuspenseQueryHook.mockReturnValue("pretend");

describe("createSuspenseQueryHook", () => {
it("creates factory function using useSWR", async () => {
const { createSuspenseQueryHook } = await import("../suspense.js");

expect(configureBaseSuspenseQueryHook).toHaveBeenLastCalledWith(useSWR);
expect(createSuspenseQueryHook).toBe("pretend");
});
});
142 changes: 142 additions & 0 deletions src/__test__/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createImmutableHook } from "../immutable.js";
import { createInfiniteHook } from "../infinite.js";
import { createMutateHook } from "../mutate.js";
import { createQueryHook } from "../query.js";
import { createSuspenseQueryHook } from "../suspense.js";
import type { TypesForRequest } from "../types.js";
import type { components, paths } from "./fixtures/petstore.js";

Expand All @@ -30,6 +31,7 @@ const client = createClient<paths>();
const useQuery = createQueryHook(client, "<unique-key>");
const useImmutable = createImmutableHook(client, "<unique-key>");
const useInfinite = createInfiniteHook(client, "<unique-key>");
const useSuspenseQuery = createSuspenseQueryHook(client, "<unique-key>");
const useMutate = createMutateHook(
client,
"<unique-key>",
Expand Down Expand Up @@ -201,6 +203,99 @@ describe("types", () => {
});
});

describe("useSuspenseQuery", () => {
it("accepts config without suspense option", () => {
useSuspenseQuery("/pet/findByStatus", null, { errorRetryCount: 1 });
});

it("does not accept suspense in config", () => {
useSuspenseQuery("/pet/findByStatus", null, {
errorRetryCount: 1,
// @ts-expect-error suspense should not be allowed in config
suspense: true,
});
});

describe("when init is required", () => {
it("does not accept path alone", () => {
// @ts-expect-error path is required
useSuspenseQuery("/pet/{petId}");
});

it("accepts path and init", () => {
useSuspenseQuery("/pet/{petId}", {
params: {
path: {
petId: 5,
},
},
});
});

it("accepts `null` init", () => {
useSuspenseQuery("/pet/{petId}", null);
});
});

describe("when init is not required", () => {
it("accepts path alone", () => {
useSuspenseQuery("/pet/findByStatus");
});

it("accepts path and init", () => {
useSuspenseQuery("/pet/findByStatus", {
params: {
query: {
status: "available",
},
},
});
});

it("accepts `null` init", () => {
useSuspenseQuery("/pet/findByStatus", null);
});
});

describe("rejects extra properties", () => {
it("in query params", () => {
useSuspenseQuery("/pet/findByStatus", {
params: {
query: {
status: "available",
// @ts-expect-error extra property should be rejected
invalid_property: "nope",
},
},
});
});

it("in path params", () => {
useSuspenseQuery("/pet/{petId}", {
params: {
path: {
petId: 5,
// @ts-expect-error extra property should be rejected
invalid_path_param: "nope",
},
},
});
});

it("in header params", () => {
useSuspenseQuery("/pet/findByStatus", {
params: {
header: {
"X-Example": "test",
// @ts-expect-error extra property should be rejected
"Invalid-Header": "nope",
},
},
});
});
});
});

describe("useInfinite", () => {
it("accepts config", () => {
useInfinite("/pet/findByStatus", () => null, {
Expand Down Expand Up @@ -418,6 +513,24 @@ describe("types", () => {
});
});

describe("useSuspenseQuery", () => {
it("returns data without undefined (suspense guarantees data)", () => {
const { data } = useSuspenseQuery("/pet/{petId}", {
params: {
path: {
petId: 5,
},
},
});
expectTypeOf(data).toEqualTypeOf<Pet>();
});

it("returns correct data for path alone", () => {
const { data } = useSuspenseQuery("/pet/findByStatus");
expectTypeOf(data).toEqualTypeOf<Pet[]>();
});
});

describe("useInfinite", () => {
it("returns correct data", () => {
const { data } = useInfinite("/pet/findByStatus", (_index, _prev) => ({
Expand Down Expand Up @@ -488,6 +601,20 @@ describe("types", () => {
});
});

describe("useSuspenseQuery", () => {
it("returns correct error", () => {
const { error } = useSuspenseQuery("/pet/{petId}", {
params: {
path: {
petId: 5,
},
},
});

expectTypeOf(error).toEqualTypeOf<PetInvalid | undefined>();
});
});

describe("useInfinite", () => {
it("returns correct error", () => {
const { error } = useInfinite("/pet/findByStatus", (_index, _prev) => ({
Expand All @@ -505,6 +632,7 @@ describe("types", () => {
const useQuery = createQueryHook<paths, `${string}/${string}`, Key, Error>(client, uniqueKey);
const useImmutable = createImmutableHook<paths, `${string}/${string}`, Key, Error>(client, uniqueKey);
const useInfinite = createInfiniteHook<paths, `${string}/${string}`, Key, Error>(client, uniqueKey);
const useSuspenseQuery = createSuspenseQueryHook<paths, `${string}/${string}`, Key, Error>(client, uniqueKey);

describe("useQuery", () => {
it("returns correct error", () => {
Expand Down Expand Up @@ -543,6 +671,20 @@ describe("types", () => {
expectTypeOf(error).toEqualTypeOf<PetStatusInvalid | Error | undefined>();
});
});

describe("useSuspenseQuery", () => {
it("returns correct error", () => {
const { error } = useSuspenseQuery("/pet/{petId}", {
params: {
path: {
petId: 5,
},
},
});

expectTypeOf(error).toEqualTypeOf<PetInvalid | Error | undefined>();
});
});
});
});

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from "./immutable.js";
export * from "./infinite.js";
export * from "./mutate.js";
export * from "./query.js";
export * from "./suspense.js";
export * from "./types.js";
52 changes: 52 additions & 0 deletions src/suspense-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Client } from "openapi-fetch";
import type { MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
import { useCallback, useDebugValue, useMemo } from "react";
import type { Fetcher, SWRHook } from "swr";
import type { Exact } from "type-fest";
import type { SuspenseSWRConfig, SuspenseSWRResponse, TypesForGetRequest } from "./types.js";

/**
* @private
*/
export function configureBaseSuspenseQueryHook(useHook: SWRHook) {
return function createSuspenseQueryBaseHook<
Paths extends {},
IMediaType extends MediaType,
Prefix extends string,
FetcherError = never,
>(client: Client<Paths, IMediaType>, prefix: Prefix) {
return function useSuspenseQuery<
Path extends PathsWithMethod<Paths, "get">,
R extends TypesForGetRequest<Paths, Path>,
Init extends Exact<R["Init"], Init>,
Data extends R["Data"],
Error extends R["Error"] | FetcherError,
>(
path: Path,
...[init, config]: RequiredKeysOf<Init> extends never
? [(Init | null)?, SuspenseSWRConfig<Data, Error>?]
: [Init | null, SuspenseSWRConfig<Data, Error>?]
): SuspenseSWRResponse<Data, Error> {
useDebugValue(`${prefix} - ${path as string}`);

const key = useMemo(() => (init !== null ? ([prefix, path, init] as const) : null), [prefix, path, init]);

type Key = typeof key;

const fetcher: Fetcher<Data, Key> = useCallback(
async ([_, path, init]) => {
// @ts-expect-error TODO: Improve internal init types
const res = await client.GET(path, init);
if (res.error) {
throw res.error;
}
return res.data as Data;
},
[client],
);

// @ts-expect-error TODO: Improve internal config types
return useHook<Data, Error, Key>(key, fetcher, { ...config, suspense: true });
};
};
}
Loading