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
8 changes: 8 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export default defineConfig({
text: "useMutate",
link: "/use-mutate",
},
{
text: 'mutate',
link: '/mutate'
},
{
text: 'useQuery',
link: '/use-query'
},
{
text: "useQuery",
link: "/use-query",
Expand Down
63 changes: 63 additions & 0 deletions docs/api/mutate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: mutate
---

# {{ $frontmatter.title }}

`mutate` is a wrapper around SWR's [global mutate][swr-global-mutate] function. It provides a type-safe mechanism for updating and revalidating SWR's client-side cache for specific endpoints.

Like global mutate, this mutate wrapper accepts three parameters: `key`, `data`, and `options`. The latter two parameters are identical to those in _bound mutate_. `key` can be either a path alone, or a path with fetch options.

The level of specificity used when defining the key will determine which cached requests are updated. If only a path is provided, any cached request using that path will be updated. If fetch options are included in the key, the [`compare`](./hook-builders.md#compare) function will determine if a cached request's fetch options match the key's fetch options.

```ts
const mutate = mutate();

await mutate([path, init], data, options);
```

## API

### Parameters

- `key`:
- `path`: Any endpoint that supports `GET` requests.
- `init`: (_optional_) Partial fetch options for the chosen endpoint.
- `data`: (_optional_)
- Data to update the client cache.
- An async function for a remote mutation.
- `options`: (_optional_) [SWR mutate options][swr-mutate-params].

### Returns

- A promise containing an array, where each array item is either updated data for a matched key or `undefined`.

> SWR's `mutate` signature specifies that when a matcher function is used, the return type will be [an array](https://github.com/vercel/swr/blob/1585a3e37d90ad0df8097b099db38f1afb43c95d/src/_internal/types.ts#L426). Since our wrapper uses a key matcher function, it will always return an array type.


## How It Works

```ts
import { mutate as swrMutate } from 'swr';

function mutate([path, init], data, opts) {
return swrMutate(
(key) => {
if (!Array.isArray(key) || ![2, 3].includes(key.length)) {
return false;
}
const [keyPrefix, keyPath, keyOptions] = key;
return (
keyPrefix === prefix &&
keyPath === path &&
(init ? compare(keyOptions, init) : true)
);
},
data,
opts,
);
}
```

[swr-global-mutate]: https://swr.vercel.app/docs/mutation#global-mutate
[swr-mutate-params]: https://swr.vercel.app/docs/mutation#parameters
126 changes: 124 additions & 2 deletions src/__test__/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from "react";
import * as SWR from "swr";
import type { ScopedMutator } from "swr/_internal";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMutateHook } from "../mutate.js";
import { createMutate, createMutateHook } from "../mutate.js";
import type { paths } from "./fixtures/petstore.js";

// Mock `useCallback` (return given function as-is)
Expand All @@ -15,9 +15,10 @@ useCallback.mockImplementation((fn) => fn);
// Mock `useSWRConfig`
const swrMutate = vi.fn<ScopedMutator>();
vi.mock("swr");
const { useSWRConfig } = vi.mocked(SWR);
const { useSWRConfig, mutate: swrGlobalMutate } = vi.mocked(SWR);
// @ts-expect-error - only `mutate` is relevant to this test
useSWRConfig.mockReturnValue({ mutate: swrMutate });
// globalMutate.mockImplementation(swrMutate);

// Setup
const client = createClient<paths>();
Expand All @@ -29,6 +30,20 @@ const getKeyMatcher = () => {
return swrMutate.mock.lastCall![0] as ScopedMutator;
};

const getGlobalKeyMatcher = () => {
if (swrGlobalMutate.mock.calls.length === 0) {
throw new Error("global `mutate` not called");
}
return swrGlobalMutate.mock.lastCall![0] as ScopedMutator;
};

const globalMutate = createMutate(
client,
"<unique-key>",
// @ts-expect-error - not going to compare for most tests
null,
);

const useMutate = createMutateHook(
client,
"<unique-key>",
Expand Down Expand Up @@ -187,3 +202,110 @@ describe("createMutateHook with lodash.isMatch as `compare`", () => {
).toBe(true);
});
});

describe("createMutate", () => {
afterEach(() => {
vi.clearAllMocks();
});

it("returns callback that invokes swr `mutate` with fn, data and options", async () => {
expect(swrGlobalMutate).not.toHaveBeenCalled();

const data = [{ name: "doggie", photoUrls: ["https://example.com"] }];
const config = { throwOnError: false };

await globalMutate(["/pet/findByStatus"], data, config);

expect(swrGlobalMutate).toHaveBeenCalledTimes(1);
expect(swrGlobalMutate).toHaveBeenLastCalledWith(
// Matcher function
expect.any(Function),
// Data
data,
// Config
config,
);
});

it("supports boolean for options argument", async () => {
expect(swrGlobalMutate).not.toHaveBeenCalled();

const data = [{ name: "doggie", photoUrls: ["https://example.com"] }];

await globalMutate(["/pet/findByStatus"], data, false);

expect(swrGlobalMutate).toHaveBeenCalledTimes(1);
expect(swrGlobalMutate).toHaveBeenLastCalledWith(
// Matcher function
expect.any(Function),
// Data
data,
// Config
false,
);
});

describe("mutate -> key matcher", () => {
it("returns false for non-array keys", async () => {
await globalMutate(["/pet/findByStatus"]);
const keyMatcher = getGlobalKeyMatcher();

expect(keyMatcher(null)).toBe(false);
expect(keyMatcher(undefined)).toBe(false);
expect(keyMatcher("")).toBe(false);
expect(keyMatcher({})).toBe(false);
});

it("returns false for arrays with length !== 3", async () => {
await globalMutate(["/pet/findByStatus"]);
const keyMatcher = getGlobalKeyMatcher();

expect(keyMatcher(Array(0))).toBe(false);
expect(keyMatcher(Array(1))).toBe(false);
expect(keyMatcher(Array(2))).toBe(false);
expect(keyMatcher(Array(4))).toBe(false);
expect(keyMatcher(Array(5))).toBe(false);
});

it("matches when prefix and path are equal and init isn't given", async () => {
await globalMutate(["/pet/findByStatus"]);
const keyMatcher = getGlobalKeyMatcher();

// Same path, no init
expect(keyMatcher(["<unique-key>", "/pet/findByStatus"])).toBe(true);

// Same path, init ignored
expect(keyMatcher(["<unique-key>", "/pet/findByStatus", { some: "init" }])).toBe(true);

// Same path, undefined init ignored
expect(keyMatcher(["<unique-key>", "/pet/findByStatus", undefined])).toBe(true);
});

it("returns compare result when prefix and path are equal and init is given", async () => {
const psudeoCompare = vi.fn().mockReturnValue("booleanPlaceholder");

const prefix = "<unique-key>";
const path = "/pet/findByStatus";
const givenInit = {};

const mutate = createMutate(client, prefix, psudeoCompare);

await mutate([path, givenInit]);
const keyMatcher = getGlobalKeyMatcher();

const result = keyMatcher([
prefix, // Same prefix -> true
path, // Same path -> true
{ some: "init" }, // Init -> should call `compare`
]);

expect(psudeoCompare).toHaveBeenLastCalledWith(
{ some: "init" }, // Init from key
givenInit, // Init given to compare
);

// Note: compare result is returned (real world would be boolean)
expect(result).toBe("booleanPlaceholder");
});
});
});
65 changes: 64 additions & 1 deletion src/mutate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,76 @@
import type { Client } from "openapi-fetch";
import type { MediaType, PathsWithMethod } from "openapi-typescript-helpers";
import { useCallback, useDebugValue } from "react";
import { type MutatorCallback, type MutatorOptions, useSWRConfig } from "swr";
import { type MutatorCallback, type MutatorOptions, mutate as swrGlobalMutate, useSWRConfig } from "swr";
import type { Exact, PartialDeep } from "type-fest";
import type { TypesForGetRequest } from "./types.js";

// Types are loose here to support ecosystem utilities like `_.isMatch`
export type CompareFn = (init: any, partialInit: any) => boolean;


/**
* Produces a typed wrapper for [global `mutate`](https://swr.vercel.app/docs/mutation#global-mutate.
*
* ```ts
* import createClient from "openapi-fetch";
* import { isMatch } from "lodash";
*
* const client = createClient();
*
* const mutate = createMutate(client, "<unique-key>", isMatch);
*
* // Revalidate all keys matching this path
* await mutate(["/pets"]);
* await mutate(["/pets"], newData);
* await mutate(["/pets"], undefined, { revalidate: true });
*
* // Revlidate all keys matching this path and this subset of options
* await mutate(
* ["/pets", { query: { limit: 10 } }],
* newData,
* { revalidate: false }
* );
* ```
*/
export function createMutate<Paths extends {}, IMediaType extends MediaType>(
client: Client<Paths, IMediaType>,
prefix: string,
compare: CompareFn,
) {
return function mutate<
Path extends PathsWithMethod<Paths, "get">,
R extends TypesForGetRequest<Paths, Path>,
Init extends Exact<R["Init"], Init>,
>(
[path, init]: [Path, PartialDeep<Init>?],
data?: R["Data"] | Promise<R["Data"]> | MutatorCallback<R["Data"]>,
opts?: boolean | MutatorOptions<R["Data"]>,
) {
return swrGlobalMutate<R["Data"], R["Data"]>((key) => {
if (
// Must be array
!Array.isArray(key) ||
// Must have 2 or 3 elements (prefix, path, optional init)
![2, 3].includes(key.length)
) {
return false;
}

const [keyPrefix, keyPath, keyOptions] = key as unknown[];

return (
// Matching prefix
keyPrefix === prefix &&
// Matching path
keyPath === path &&
// Matching options
(init ? compare(keyOptions, init) : true)
);
}, data, opts)
}
}

/**
* Produces a typed wrapper for [`useSWRConfig#mutate`](https://swr.vercel.app/docs/mutation).
*
Expand Down