authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 15:24:00-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:51:55-08:00
logde7e1ab0f88b02444249d69a59c5fed7c981b6c9
treedf9542e7a2599e14d6b9ade21edb5d9f27b34088
parentb222f6adf6944284280e7ff42bdb6bb2242bce84
signaturelock-open Commit is signed but in an unrecognized format.

feat: bikeshedding and success messages


9 files changed, 289 insertions(+), 170 deletions(-)

example/src/App.tsx+3-3
...@@ -3,7 +3,7 @@ import {...@@ -3,7 +3,7 @@ import {
3 createMutationButton,3 createMutationButton,
4 MutationClient,4 MutationClient,
5 queryClientOptimisticHelpers,5 queryClientOptimisticHelpers,
6 useMutation,6 useMutate,
7} from "@clo/react-mutation";7} from "@clo/react-mutation";
8import { queryOptions as queryOptions } from "@tanstack/react-query";8import { queryOptions as queryOptions } from "@tanstack/react-query";
9import { useSuspenseQuery } from "@tanstack/react-query";9import { useSuspenseQuery } from "@tanstack/react-query";
...@@ -49,7 +49,7 @@ const queryCounter = queryOptions({...@@ -49,7 +49,7 @@ const queryCounter = queryOptions({
49// await client.invalidateQueries(queryCounter);49// await client.invalidateQueries(queryCounter);
50// },50// },
51// });51// });
52const mutIncrement = mutationClient.defineBatched({52const mutIncrement = mutationClient.defineDebounced({
53 mode: "debounce",53 mode: "debounce",
54 time: 200,54 time: 200,
5555
...@@ -92,7 +92,7 @@ const MutationButton = createMutationButton(CustomButton);...@@ -92,7 +92,7 @@ const MutationButton = createMutationButton(CustomButton);
9292
93function Counter() {93function Counter() {
94 const { data: { count } } = useSuspenseQuery(queryCounter);94 const { data: { count } } = useSuspenseQuery(queryCounter);
95 const mutation = useMutation(mutIncrement);95 const mutation = useMutate(mutIncrement);
9696
97 return (97 return (
98 <div className="counter-card">98 <div className="counter-card">
readme.md+74-28
...@@ -10,7 +10,7 @@ patterns and verbose code that is hard to review....@@ -10,7 +10,7 @@ patterns and verbose code that is hard to review.
1010
11The primary gains React Mutation provides are11The primary gains React Mutation provides are
1212
13- **Automatic error handling**. If a `useMutation` hook does not observe13- **Automatic error handling**. If a `useMutate` hook does not observe
14 `isError`, unhandled errors will be propagated to a global handler, which can14 `isError`, unhandled errors will be propagated to a global handler, which can
15 display a UI toast. Otherwise, the component can display the error locally.15 display a UI toast. Otherwise, the component can display the error locally.
16- Optimistic helpers allow defining rollbacks and refetching logic independant16- Optimistic helpers allow defining rollbacks and refetching logic independant
...@@ -22,13 +22,13 @@ The primary gains React Mutation provides are...@@ -22,13 +22,13 @@ The primary gains React Mutation provides are
22This library declares two kinds of mutations. Each kind has different behavior22This library declares two kinds of mutations. Each kind has different behavior
23around concurrent operations.23around concurrent operations.
2424
25- [**Queued Mutations**](#Queued-Mutations): A mutation blocks the UI until it25- [**Blocking Mutations**](#Blocking-Mutations): A mutation blocks the UI until it
26 is complete. You press a button, a pending state appears, then it completes.26 is complete. You press a button, a pending state appears, then it completes.
27 This works great for forms, creations and deletions, and is similar to React27 This works great for forms, creations and deletions, and is similar to React
28 Query's mutation system.28 Query's mutation system.
29- [**Batched Mutations**](#Batched-Mutations): Each call to the mutation applies29- [**Debounced Mutations**](#Debounced-Mutations): Each call to the mutation applies
30 new optimistic state, and after a debounce or throttle, the new optimistic30 new optimistic state, and after a debounce (or throttle) the new optimistic
31 state is committed to the API. UI never shows a pending state for batches.31 state is committed to the API. UI never shows a pending state for these.
32 This works great for auto-saving input fields, follow buttons, and is32 This works great for auto-saving input fields, follow buttons, and is
33 preferred whenever possible.33 preferred whenever possible.
3434
...@@ -37,7 +37,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an...@@ -37,7 +37,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an
37```ts37```ts
38const queryClient = new QueryClient();38const queryClient = new QueryClient();
39export const mutations = new MutationClient({39export const mutations = new MutationClient({
40 // All properties in `context` are available within mutation functions.40 // All properties in `context` are available within every function.
41 context: {41 context: {
42 client: queryClient,42 client: queryClient,
43 // Can add any easy helpers for your codebase.43 // Can add any easy helpers for your codebase.
...@@ -45,29 +45,40 @@ export const mutations = new MutationClient({...@@ -45,29 +45,40 @@ export const mutations = new MutationClient({
45 get: (k: QueryKey) => client.getQueryData(k),45 get: (k: QueryKey) => client.getQueryData(k),
46 },46 },
47 47
48 // Optimistic helpers are a second type of context, only available48 // Optimistic helpers are a second type of context, only available within
49 // within optimistic update functions. The built in React Query helpers49 // optimistic update functions. These functions are bound to each mutation,
50 // add many query cache mutating operations that automatically50 // which means they can handle automatic rollbacks and query invalidation.
51 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),51 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
52 52
53 // When call sites do not opt into handling errors, or a pending53 // When call sites do not opt into handling errors, or a pending
54 // mutation hook is unmounted, errors are sent to this function.54 // mutation hook is unmounted, errors are sent to this function.
55 // An example is to bind this to global a UI toast.55 // An example is to bind this to global a UI toast.
56 reportError(description: string, error: unknown) {56 reportError(userFriendlyErrorMessage: string, error: unknown) {
57 console.error("Mutation error:", error);57 showToastUI("error", userFriendlyErrorMessage);
58 console.error(error); // or send to telemetry
59 },
60
61 // Similarly, when call sites do opt into handling success.
62 reportSuccess(userFriendlySuccessMessage: string) {
63 showToastUI("success", userFriendlyErrorMessage);
58 },64 },
59})65})
6066
61```67```
6268
63### Queued Mutations69### Blocking Mutations
6470
65A queued mutation is defined with `mutations.defineQueued`.71A blocking mutation is defined with `mutations.defineBlocking`. Example use cases:
72
73- A form to create a new resource.
74- Button operations such as deleting or resyncing.
75- Any case where it is unclear what the optimistic state should be.
6676
67```tsx77```tsx
68const queryItemList = queryOptions({ ... });78const queryItemList = queryOptions({ ... });
69const queryItem = (id: string) => queryOptions({ ... });79const queryItem = (id: string) => queryOptions({ ... });
7080
81// The convention is to name handlers starting with `mut`
71const mutDeleteItem = mutations.defineQueued({82const mutDeleteItem = mutations.defineQueued({
72 // `mutate` comes first, is only worried about syncing with the backend.83 // `mutate` comes first, is only worried about syncing with the backend.
73 async mutate(id: string) {84 async mutate(id: string) {
...@@ -76,23 +87,31 @@ const mutDeleteItem = mutations.defineQueued({...@@ -76,23 +87,31 @@ const mutDeleteItem = mutations.defineQueued({
76 },87 },
77 88
78 optimistic({ client, get, helpers, args: [id] }) {89 optimistic({ client, get, helpers, args: [id] }) {
90 // Remove the matching items, but restore and refetch them on failure.
79 helpers.arrayRemove(queryItemList, (item) => item === id);91 helpers.arrayRemove(queryItemList, (item) => item === id);
92 // Remove this query from the client, but restore as stale and refetch it on failure.
80 helpers.removeQuery(queryItem);93 helpers.removeQuery(queryItem);
81 },94 },
82 95
96 // Example: `Could not {description}`
83 describe({ get, args: [id] }) {97 describe({ get, args: [id] }) {
84 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";98 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";
85 return `delete '${title}'`;99 return `Delete '${get(queryItem(id))?.title ?? 'unknown'}'`;
86 },100 },
101 // Example: `Successfully {description}`
102 describeResult: ({ get, args: [id] }) =>
103 `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
87 104
88 // since the optimistic handler is perfect, there is no need105 // Since the optimistic handler is perfect, there is no need
89 // to refetch any data once a success case is hit.106 // to refetch any data once a success case is hit.
90 refetchOnSuccess: false,107 refetchOnSuccess: false,
91});108});
92109
110// React example. Since `error` and `result` are not destructed, messages are
111// indicated through UI toasts from the mutation client.
93export function Example({ id }: { id: string }) {112export function Example({ id }: { id: string }) {
94 const { data: list } = useSuspenseQuery(queryItemList);113 const { data: list } = useSuspenseQuery(queryItemList);
95 const { run } = useMutation(mutDeleteItem);114 const { run } = useMutate(mutDeleteItem);
96 115
97 return list.map((id) => <li key={id}>116 return list.map((id) => <li key={id}>
98 <Item id={id} />117 <Item id={id} />
...@@ -101,27 +120,25 @@ export function Example({ id }: { id: string }) {...@@ -101,27 +120,25 @@ export function Example({ id }: { id: string }) {
101}120}
102```121```
103122
104### Batched Mutations123### Debounced Mutations
105124
106A batched mutation is defined with `mutations.defineBatched`.125A debounced mutation is defined with `mutations.defineBatched`.
107126
108```tsx127```tsx
109const mutSetItemName = mutationClient.defineBatched({128const mutSetItemName = mutationClient.defineDebounced({
110 mode: "debounce",129 // Think of your mutator in terms of how it applies optimistic state.
111 time: 200,
112
113 // start by mutating the optimistic state
114 optimistic({ helpers }, id: string, name: string) {130 optimistic({ helpers }, id: string, name: string) {
115 helpers.objSet(queryItem(id), ["title"], name);131 helpers.objSet(queryItem(id), ["title"], name);
116 },132 },
117 // a value is snapshot before calling `optimistic` and after the133 // A value is snapshotted *before* calling `optimistic`, and then again after
118 // timer. if the snapshots differ, the `commit` function is called.134 // the timer. If the snapshots differ, then `commit` function is called.
119 getValue: ({ get }) => get(queryCounter)?.title ?? "",135 getValue: ({ get }) => get(queryCounter)?.title ?? "",
120 136
121 // batch the same `id`s together137 // Split different `id`s into their own debounces.
122 key: ({ args: [id] }) => id,138 key: ({ args: [id] }) => id,
123139
124 // commit the result to the backend140 // Commit the result to the backend. Here, you can observe the two snapshotted
141 // values and form an API request.
125 async commit({ initial, current, args: [id] }) {142 async commit({ initial, current, args: [id] }) {
126 const response = await fetch(`/items/${id}`, {143 const response = await fetch(`/items/${id}`, {
127 method: "patch",144 method: "patch",
...@@ -130,6 +147,35 @@ const mutSetItemName = mutationClient.defineBatched({...@@ -130,6 +147,35 @@ const mutSetItemName = mutationClient.defineBatched({
130 if (!response.ok) throw new Error(`HTTP ${response.status}`);147 if (!response.ok) throw new Error(`HTTP ${response.status}`);
131 },148 },
132149
133 describe: ({ get }) => `rename '${get(queryItem())?.title ?? 'unknown'}'`,150 describe: ({ get, args: [id] }) =>
151 `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`,
152 describeResult: ({ get, args: [id] }) =>
153 `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
134});154});
155
156// React example. Since the error and result are read in this hook,
157// the success and failure states will be driven through the component UI.
158function Item({ id }: { id: string }) {
159 const { data: item } = useSuspenseQuery(queryItem(id));
160 const { run, isSuccess, errorMessage } = useMutate(mutDeleteItem);
161
162 // TODO: test this pattern. maybe introduce another hook for doing good input
163 // fields that hook could also support an "Undo" button.
164 return <>
165 <input
166 value={item.title}
167 onChange={(e) => {
168 run(e.target.value);
169 }}
170 />
171 {
172 isSuccess
173 ? "Saved"
174 : errorMessage
175 ? "Error: " + errorMessage : null
176 }
177 </>
178}
135```179```
180
181###
src/batch.ts+47-5
...@@ -23,7 +23,10 @@ export interface BatchMutationOptions<...@@ -23,7 +23,10 @@ export interface BatchMutationOptions<
23 */23 */
24 getValue: (context: Config["context"], ...args: Args) => Optimistic;24 getValue: (context: Config["context"], ...args: Args) => Optimistic;
2525
26 mode: "debounce" | "throttle";26 /**
27 * @default "debounce"
28 */
29 mode?: "debounce" | "throttle";
27 /**30 /**
28 * Milliseconds31 * Milliseconds
29 * @default 20032 * @default 200
...@@ -50,6 +53,17 @@ export interface BatchMutationOptions<...@@ -50,6 +53,17 @@ export interface BatchMutationOptions<
50 | ((53 | ((
51 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,54 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,
52 ) => string);55 ) => string);
56 /**
57 * Used in success messages.
58 * Phrase it as a complete success message, e.g., "Renamed item successfully"
59 * Set to null to suppress success reporting.
60 */
61 describeResult?:
62 | string
63 | ((
64 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config> & { result: Result },
65 ) => string)
66 | null;
53 /**67 /**
54 * Refetch all of the data this mutation could have affected.68 * Refetch all of the data this mutation could have affected.
55 */69 */
...@@ -97,6 +111,7 @@ interface BatchChannel<Args extends unknown[], Result, Optimistic> {...@@ -97,6 +111,7 @@ interface BatchChannel<Args extends unknown[], Result, Optimistic> {
97 args: Args;111 args: Args;
98 resolve: (result: Result) => void;112 resolve: (result: Result) => void;
99 reject: (error: unknown) => void;113 reject: (error: unknown) => void;
114 reportSuccessGlobally?: boolean;
100 }>;115 }>;
101}116}
102117
...@@ -197,15 +212,33 @@ export class BatchMutation<...@@ -197,15 +212,33 @@ export class BatchMutation<
197 return describe;212 return describe;
198 }213 }
199214
200 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */215 describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined {
216 const { describeResult } = this.#options;
217 if (describeResult === null || describeResult === undefined) return undefined;
218 return typeof describeResult === "function"
219 ? describeResult({
220 ...this.#client.context,
221 args,
222 initial,
223 current,
224 result,
225 })
226 : describeResult;
227 }
228
229 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
201 run(...args: Args): void {230 run(...args: Args): void {
202 this.runAndReturn(...args).catch((error) => {231 this.#runAndReturn(args, true).catch((error) => {
203 this.#client.reportError(error);232 this.#client.reportError(error);
204 });233 });
205 }234 }
206235
207 /** Calls the mutation, treating the errors as promise rejection. */236 /** Calls the mutation, treating the errors as promise rejection. */
208 runAndReturn(...args: Args): Promise<Result> {237 runAndReturn(...args: Args): Promise<Result> {
238 return this.#runAndReturn(args, false);
239 }
240
241 #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise<Result> {
209 const key = this.key(args);242 const key = this.key(args);
210 const channel = this.#getOrPutChannel(key);243 const channel = this.#getOrPutChannel(key);
211244
...@@ -260,7 +293,7 @@ export class BatchMutation<...@@ -260,7 +293,7 @@ export class BatchMutation<
260293
261 // Create promise for this caller294 // Create promise for this caller
262 const { promise, resolve, reject } = Promise.withResolvers<Result>();295 const { promise, resolve, reject } = Promise.withResolvers<Result>();
263 channel.pending.push({ args, resolve, reject });296 channel.pending.push({ args, resolve, reject, reportSuccessGlobally });
264297
265 // Set status to waiting and notify298 // Set status to waiting and notify
266 if (channel.status === "idle") {299 if (channel.status === "idle") {
...@@ -280,7 +313,7 @@ export class BatchMutation<...@@ -280,7 +313,7 @@ export class BatchMutation<
280 ) {313 ) {
281 const time = this.#options.time ?? 200;314 const time = this.#options.time ?? 200;
282315
283 if (this.#options.mode === "debounce") {316 if (this.#options.mode !== "throttle") {
284 // Debounce: reset timer on each call317 // Debounce: reset timer on each call
285 if (channel.timer !== null) {318 if (channel.timer !== null) {
286 clearTimeout(channel.timer);319 clearTimeout(channel.timer);
...@@ -364,6 +397,15 @@ export class BatchMutation<...@@ -364,6 +397,15 @@ export class BatchMutation<
364 // Resolve all pending promises397 // Resolve all pending promises
365 pendingItems.forEach(({ resolve }) => resolve(result));398 pendingItems.forEach(({ resolve }) => resolve(result));
366399
400 // Report success globally if any of the pending items requested it
401 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
402 if (shouldReportSuccess) {
403 const message = this.describeResult(firstArgs, initial, current, result);
404 if (message && this.#client.reportSuccess) {
405 this.#client.reportSuccess(message);
406 }
407 }
408
367 // Record commit time for throttle mode409 // Record commit time for throttle mode
368 channel.lastCommitTime = Date.now();410 channel.lastCommitTime = Date.now();
369411
src/client.ts+5-2
...@@ -22,6 +22,7 @@ export interface MutationClientOptions<...@@ -22,6 +22,7 @@ export interface MutationClientOptions<
22 events: OptimisticEvents,22 events: OptimisticEvents,
23 ) => OptimisticHelpers;23 ) => OptimisticHelpers;
24 reportError: (error: unknown) => void;24 reportError: (error: unknown) => void;
25 reportSuccess?: (message: string) => void;
25 /**26 /**
26 * Compare two values for deep equality. Used by BatchMutation to determine27 * Compare two values for deep equality. Used by BatchMutation to determine
27 * if the optimistic state has changed from the initial snapshot.28 * if the optimistic state has changed from the initial snapshot.
...@@ -42,12 +43,14 @@ export class MutationClient<...@@ -42,12 +43,14 @@ export class MutationClient<
42 context: Context;43 context: Context;
43 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;44 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
44 reportError: (error: unknown) => void;45 reportError: (error: unknown) => void;
46 reportSuccess?: (message: string) => void;
45 deepEquals: (a: unknown, b: unknown) => boolean;47 deepEquals: (a: unknown, b: unknown) => boolean;
4648
47 constructor(options: MutationClientOptions<Context, OptimisticHelpers>) {49 constructor(options: MutationClientOptions<Context, OptimisticHelpers>) {
48 this.context = options.context;50 this.context = options.context;
49 this.getOptimisticHelpers = options.getOptimisticHelpers;51 this.getOptimisticHelpers = options.getOptimisticHelpers;
50 this.reportError = options.reportError;52 this.reportError = options.reportError;
53 this.reportSuccess = options.reportSuccess;
51 this.deepEquals = options.deepEquals ?? defaultDeepEquals;54 this.deepEquals = options.deepEquals ?? defaultDeepEquals;
52 }55 }
5356
...@@ -56,7 +59,7 @@ export class MutationClient<...@@ -56,7 +59,7 @@ export class MutationClient<
56 * You press a button, a pending state appears, then it completes. This works59 * You press a button, a pending state appears, then it completes. This works
57 * great for forms, and is similar to React Query's mutation system.60 * great for forms, and is similar to React Query's mutation system.
58 */61 */
59 defineQueued<const Args extends unknown[], Result>(62 defineBlocking<const Args extends unknown[], Result>(
60 options: MutationOptions<63 options: MutationOptions<
61 Args,64 Args,
62 Result,65 Result,
...@@ -77,7 +80,7 @@ export class MutationClient<...@@ -77,7 +80,7 @@ export class MutationClient<
77 * works great for auto-saving input fields, follow buttons, and is preferred80 * works great for auto-saving input fields, follow buttons, and is preferred
78 * whenever possible.81 * whenever possible.
79 */82 */
80 defineBatched<const Args extends unknown[], Result, Optimistic>(83 defineDebounced<const Args extends unknown[], Result, Optimistic>(
81 options: BatchMutationOptions<84 options: BatchMutationOptions<
82 Args,85 Args,
83 Result,86 Result,
src/mod.ts+6-6
...@@ -14,10 +14,10 @@ export type { Mutation, MutationEvent } from "./types.ts";...@@ -14,10 +14,10 @@ export type { Mutation, MutationEvent } from "./types.ts";
14export {14export {
15 createMutationButton,15 createMutationButton,
16 type MutationButtonProps,16 type MutationButtonProps,
17 useMutation,17 useMutate,
18 type UseMutationError,18 type UseMutateError,
19 type UseMutationIdle,19 type UseMutateIdle,
20 type UseMutationResult,20 type UseMutateResult,
21 type UseMutationResultBase,21 type UseMutateResultBase,
22 type UseMutationSuccess,22 type UseMutateSuccess,
23} from "./react.tsx";23} from "./react.tsx";
src/queued.ts+26-3
...@@ -19,13 +19,22 @@ export interface MutationOptions<...@@ -19,13 +19,22 @@ export interface MutationOptions<
19 * params type is used to allow type inference. Place this function first to19 * params type is used to allow type inference. Place this function first to
20 * ensure TypeScript correctly infers the argument type for the rest of the20 * ensure TypeScript correctly infers the argument type for the rest of the
21 * functions.21 * functions.
22 *
23 * In practice, optimistic context is never needed in this function, but it
24 * is provided as the `this` value if you truly desire it.
22 */25 */
23 mutate: (context: Config["context"], ...args: Args) => Promise<Result>;26 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
24 /**27 /**
25 * Used in error messages and debug tools.28 * Used in error messages and debug tools.
26 * Phrase it considering the template `Failed to ${describe(...)}`29 * Phrase it considering the template `Failed to ${describe(...)}`
27 */30 */
28 describe: string | ((context: Config["context"] & { args: Args }) => string);31 describe: string | ((context: Config["context"] & { args: Args }) => string);
32 /**
33 * Used in success messages.
34 * Phrase it as a complete success message, e.g., "Deleted item successfully"
35 * Set to null to suppress success reporting.
36 */
37 describeResult?: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null;
29 /**38 /**
30 * Specifying the optimistic strategy is required. To disable, pass an empty39 * Specifying the optimistic strategy is required. To disable, pass an empty
31 * function with a comment to document why it isn't needed.40 * function with a comment to document why it isn't needed.
...@@ -33,6 +42,7 @@ export interface MutationOptions<...@@ -33,6 +42,7 @@ export interface MutationOptions<
33 optimistic: (context: OptimisticContext<Args, Result, Config>) => void;42 optimistic: (context: OptimisticContext<Args, Result, Config>) => void;
34 /**43 /**
35 * Refetch all of the data this mutation could have affected.44 * Refetch all of the data this mutation could have affected.
45 * Normally, optimistic helpers will perform
36 * This is called automatically on errors.46 * This is called automatically on errors.
37 */47 */
38 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;48 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;
...@@ -154,9 +164,22 @@ export class QueuedMutation<...@@ -154,9 +164,22 @@ export class QueuedMutation<
154 : describe;164 : describe;
155 }165 }
156166
167 describeResult(args: Args, result: Result): string | undefined {
168 const { describeResult } = this.#options;
169 if (describeResult === null || describeResult === undefined) return undefined;
170 return typeof describeResult === "function"
171 ? describeResult({ ...this.#client.context, args, result })
172 : describeResult;
173 }
174
157 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */175 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
158 run(...args: Args) {176 run(...args: Args) {
159 this.runAndReturn(...args).catch((error) => {177 this.runAndReturn(...args).then((result) => {
178 const message = this.describeResult(args, result);
179 if (message && this.#client.reportSuccess) {
180 this.#client.reportSuccess(message);
181 }
182 }).catch((error) => {
160 this.#client.reportError(error);183 this.#client.reportError(error);
161 });184 });
162 }185 }
...@@ -244,7 +267,7 @@ export class QueuedMutation<...@@ -244,7 +267,7 @@ export class QueuedMutation<
244 channel.status = "mutating";267 channel.status = "mutating";
245 this.#notify(channel, "mutating");268 this.#notify(channel, "mutating");
246269
247 this.#options.mutate(this.#client.context, ...args).then((result) => {270 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
248 // remove rollbacks and apply optimistic success handlers271 // remove rollbacks and apply optimistic success handlers
249 channel.rollbacks.splice(0, item.rollbacks);272 channel.rollbacks.splice(0, item.rollbacks);
250 onSuccess.forEach((cb) => cb(result));273 onSuccess.forEach((cb) => cb(result));
src/react.tsx+24-21
...@@ -13,12 +13,12 @@ import type { Mutation } from "./types.ts";...@@ -13,12 +13,12 @@ import type { Mutation } from "./types.ts";
13 * Subscribe to a mutation's status, as well as accessing a local `run` method.13 * Subscribe to a mutation's status, as well as accessing a local `run` method.
14 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.14 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.
15 */15 */
16export function useMutation<16export function useMutate<
17 Args extends unknown[],17 Args extends unknown[],
18 Result,18 Result,
19>(19>(
20 mutation: Mutation<Args, Result> | null,20 mutation: Mutation<Args, Result> | null,
21): UseMutationResult<Args, Result> {21): UseMutateResult<Args, Result> {
22 const [_, setRerender] = useState(0);22 const [_, setRerender] = useState(0);
23 const [observer] = useState(() => new Observer<Args, Result>(setRerender));23 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
24 useEffect(() => () => void observer.reset(), []);24 useEffect(() => () => void observer.reset(), []);
...@@ -29,20 +29,20 @@ export function useMutation<...@@ -29,20 +29,20 @@ export function useMutation<
29 return observer.binding;29 return observer.binding;
30}30}
3131
32export type UseMutationResult<Args extends unknown[], Result> =32export type UseMutateResult<Args extends unknown[], Result> =
33 & UseMutationResultBase<Args>33 & UseMutateResultBase<Args>
34 & (34 & (
35 | UseMutationSuccess<Result>35 | UseMutateSuccess<Result>
36 | UseMutationError36 | UseMutateError
37 | UseMutationIdle37 | UseMutateIdle
38 );38 );
3939
40export interface UseMutationResultBase<Args extends unknown[]> {40export interface UseMutateResultBase<Args extends unknown[]> {
41 run: (...args: Args) => void;41 run: (...args: Args) => void;
42 clear: () => void;42 clear: () => void;
43}43}
4444
45export interface UseMutationSuccess<Result> {45export interface UseMutateSuccess<Result> {
46 status: "success";46 status: "success";
47 result: Result;47 result: Result;
48 error: undefined;48 error: undefined;
...@@ -57,7 +57,7 @@ export interface UseMutationSuccess<Result> {...@@ -57,7 +57,7 @@ export interface UseMutationSuccess<Result> {
57 /** `true` when there is optimistic state applied. */57 /** `true` when there is optimistic state applied. */
58 isOptimisticData: boolean;58 isOptimisticData: boolean;
59}59}
60export interface UseMutationError {60export interface UseMutateError {
61 status: "error";61 status: "error";
62 result: undefined;62 result: undefined;
63 error: unknown;63 error: unknown;
...@@ -72,7 +72,7 @@ export interface UseMutationError {...@@ -72,7 +72,7 @@ export interface UseMutationError {
72 /** `true` when there is optimistic state applied. */72 /** `true` when there is optimistic state applied. */
73 isOptimisticData: boolean;73 isOptimisticData: boolean;
74}74}
75export interface UseMutationIdle {75export interface UseMutateIdle {
76 status: "idle" | "mutating";76 status: "idle" | "mutating";
77 result: undefined;77 result: undefined;
78 error: undefined;78 error: undefined;
...@@ -90,7 +90,7 @@ export interface UseMutationIdle {...@@ -90,7 +90,7 @@ export interface UseMutationIdle {
9090
91type AnyMutationState<Result> =91type AnyMutationState<Result> =
92 & Omit<92 & Omit<
93 UseMutationIdle,93 UseMutateIdle,
94 "status" | "result" | "error" | "isSuccess" | "isError"94 "status" | "result" | "error" | "isSuccess" | "isError"
95 >95 >
96 & {96 & {
...@@ -147,7 +147,7 @@ class Observer<Args extends unknown[], Result> {...@@ -147,7 +147,7 @@ class Observer<Args extends unknown[], Result> {
147 this.state = initialState();147 this.state = initialState();
148 }148 }
149149
150 binding: UseMutationResult<Args, Result> = ((self: this) => ({150 binding: UseMutateResult<Args, Result> = ((self: this) => ({
151 run(...args: Args) {151 run(...args: Args) {
152 const mutation = self.mutation;152 const mutation = self.mutation;
153 if (!mutation) return;153 if (!mutation) return;
...@@ -189,10 +189,13 @@ class Observer<Args extends unknown[], Result> {...@@ -189,10 +189,13 @@ class Observer<Args extends unknown[], Result> {
189 },189 },
190 );190 );
191 }191 }
192 // use global error handling if this usage of the hook doesnt check for192 // use global error/success handling if this usage of the hook doesn't check for
193 // errors this makes it act pretty awesome in terms of defaults. you don't193 // errors or success. This makes it act pretty awesome in terms of defaults.
194 // have to worry about the errors, they'll surface exactly once.194 // You don't have to worry about the errors/successes, they'll surface exactly once.
195 if (self.watched.has("isError") || self.watched.has("error")) {195 if (
196 self.watched.has("isError") || self.watched.has("error") ||
197 self.watched.has("isSuccess") || self.watched.has("result")
198 ) {
196 mutation.runAndReturn(...args).catch(() => {199 mutation.runAndReturn(...args).catch(() => {
197 // caught in event listener200 // caught in event listener
198 });201 });
...@@ -243,7 +246,7 @@ class Observer<Args extends unknown[], Result> {...@@ -243,7 +246,7 @@ class Observer<Args extends unknown[], Result> {
243 self.watched.add("isOptimisticData");246 self.watched.add("isOptimisticData");
244 return self.state.isOptimisticData;247 return self.state.isOptimisticData;
245 },248 },
246 } as UseMutationResult<Args, Result>))(this);249 } as UseMutateResult<Args, Result>))(this);
247}250}
248251
249interface BaseButtonProps {252interface BaseButtonProps {
...@@ -263,7 +266,7 @@ interface MutationButtonComponent<Props> {...@@ -263,7 +266,7 @@ interface MutationButtonComponent<Props> {
263export interface MutationButtonProps<Args extends unknown[], Result> {266export interface MutationButtonProps<Args extends unknown[], Result> {
264 mutation:267 mutation:
265 | Mutation<Args, Result>268 | Mutation<Args, Result>
266 | Pick<UseMutationResult<Args, Result>, "run" | "status" | "isPending">;269 | Pick<UseMutateResult<Args, Result>, "run" | "status" | "isPending">;
267 /** Preventing default will interrupt the mutation */270 /** Preventing default will interrupt the mutation */
268 args: Args | ((e: MouseEvent) => Args | null);271 args: Args | ((e: MouseEvent) => Args | null);
269 /** Preventing default will interrupt the mutation */272 /** Preventing default will interrupt the mutation */
...@@ -274,7 +277,7 @@ export interface MutationButtonProps<Args extends unknown[], Result> {...@@ -274,7 +277,7 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
274 * Wraps a custom button component with logic to execute a mutation. The wrapped277 * Wraps a custom button component with logic to execute a mutation. The wrapped
275 * component must accept `onClick` and an `isPending` property. When the inner278 * component must accept `onClick` and an `isPending` property. When the inner
276 * component emits `onClick`, that will begin the mutation. This is a trival279 * component emits `onClick`, that will begin the mutation. This is a trival
277 * abstraction on top of `useMutation`, but with type gymnastics to allow safe280 * abstraction on top of `useMutate`, but with type gymnastics to allow safe
278 * types.281 * types.
279 */282 */
280export function createMutationButton<Props>(283export function createMutationButton<Props>(
...@@ -315,7 +318,7 @@ function GenericMutationButton<...@@ -315,7 +318,7 @@ function GenericMutationButton<
315 const { mutation, args, onClick, ...forwarded } = props;318 const { mutation, args, onClick, ...forwarded } = props;
316 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;319 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
317320
318 const localHook = useMutation("subscribe" in mutation ? mutation : null);321 const localHook = useMutate("subscribe" in mutation ? mutation : null);
319 const state = "subscribe" in mutation ? localHook : mutation;322 const state = "subscribe" in mutation ? localHook : mutation;
320323
321 return (324 return (
test/batch.test.ts+30-28
...@@ -65,7 +65,7 @@ test("BatchMutation - basic mutation success with debounce", async () => {...@@ -65,7 +65,7 @@ test("BatchMutation - basic mutation success with debounce", async () => {
65 let commitCallCount = 0;65 let commitCallCount = 0;
66 let refetchCallCount = 0;66 let refetchCallCount = 0;
6767
68 const mutation = client.defineBatched({68 const mutation = client.defineDebounced({
69 optimistic({ helpers }, amount: number) {69 optimistic({ helpers }, amount: number) {
70 helpers.increment("counter", amount);70 helpers.increment("counter", amount);
71 },71 },
...@@ -98,7 +98,7 @@ test("BatchMutation - run() catches errors", async () => {...@@ -98,7 +98,7 @@ test("BatchMutation - run() catches errors", async () => {
98 testStore.clear();98 testStore.clear();
99 testStore.set("counter", 0);99 testStore.set("counter", 0);
100100
101 const mutation = client.defineBatched({101 const mutation = client.defineDebounced({
102 optimistic({ helpers }, amount: number) {102 optimistic({ helpers }, amount: number) {
103 helpers.increment("counter", amount);103 helpers.increment("counter", amount);
104 },104 },
...@@ -125,7 +125,7 @@ test("BatchMutation - runAndReturn() rejects on error", async () => {...@@ -125,7 +125,7 @@ test("BatchMutation - runAndReturn() rejects on error", async () => {
125 testStore.clear();125 testStore.clear();
126 testStore.set("counter", 0);126 testStore.set("counter", 0);
127127
128 const mutation = client.defineBatched({128 const mutation = client.defineDebounced({
129 optimistic({ helpers }, amount: number) {129 optimistic({ helpers }, amount: number) {
130 helpers.increment("counter", amount);130 helpers.increment("counter", amount);
131 },131 },
...@@ -159,7 +159,7 @@ test("BatchMutation - debounce batches rapid calls", async () => {...@@ -159,7 +159,7 @@ test("BatchMutation - debounce batches rapid calls", async () => {
159 let commitCallCount = 0;159 let commitCallCount = 0;
160 const commitArgs: Array<{ initial: number; current: number }> = [];160 const commitArgs: Array<{ initial: number; current: number }> = [];
161161
162 const mutation = client.defineBatched({162 const mutation = client.defineDebounced({
163 optimistic({ helpers }, amount: number) {163 optimistic({ helpers }, amount: number) {
164 helpers.increment("counter", amount);164 helpers.increment("counter", amount);
165 },165 },
...@@ -201,7 +201,7 @@ test("BatchMutation - debounce resets timer on each call", async () => {...@@ -201,7 +201,7 @@ test("BatchMutation - debounce resets timer on each call", async () => {
201201
202 let commitCallCount = 0;202 let commitCallCount = 0;
203203
204 const mutation = client.defineBatched({204 const mutation = client.defineDebounced({
205 optimistic({ helpers }, amount: number) {205 optimistic({ helpers }, amount: number) {
206 helpers.increment("counter", amount);206 helpers.increment("counter", amount);
207 },207 },
...@@ -250,7 +250,7 @@ test("BatchMutation - debounce separates batches after timeout", async () => {...@@ -250,7 +250,7 @@ test("BatchMutation - debounce separates batches after timeout", async () => {
250 let commitCallCount = 0;250 let commitCallCount = 0;
251 const commitArgs: Array<{ initial: number; current: number }> = [];251 const commitArgs: Array<{ initial: number; current: number }> = [];
252252
253 const mutation = client.defineBatched({253 const mutation = client.defineDebounced({
254 optimistic({ helpers }, amount: number) {254 optimistic({ helpers }, amount: number) {
255 helpers.increment("counter", amount);255 helpers.increment("counter", amount);
256 },256 },
...@@ -295,7 +295,7 @@ test("BatchMutation - throttle commits immediately on first call", async () => {...@@ -295,7 +295,7 @@ test("BatchMutation - throttle commits immediately on first call", async () => {
295 let commitTime = 0;295 let commitTime = 0;
296 const startTime = Date.now();296 const startTime = Date.now();
297297
298 const mutation = client.defineBatched({298 const mutation = client.defineDebounced({
299 optimistic({ helpers }, amount: number) {299 optimistic({ helpers }, amount: number) {
300 helpers.increment("counter", amount);300 helpers.increment("counter", amount);
301 },301 },
...@@ -325,7 +325,7 @@ test("BatchMutation - throttle batches calls within time window", async () => {...@@ -325,7 +325,7 @@ test("BatchMutation - throttle batches calls within time window", async () => {
325 let commitCallCount = 0;325 let commitCallCount = 0;
326 const commitArgs: Array<{ initial: number; current: number }> = [];326 const commitArgs: Array<{ initial: number; current: number }> = [];
327327
328 const mutation = client.defineBatched({328 const mutation = client.defineDebounced({
329 optimistic({ helpers }, amount: number) {329 optimistic({ helpers }, amount: number) {
330 helpers.increment("counter", amount);330 helpers.increment("counter", amount);
331 },331 },
...@@ -377,7 +377,7 @@ test("BatchMutation - throttle allows new batch after time window", async () =>...@@ -377,7 +377,7 @@ test("BatchMutation - throttle allows new batch after time window", async () =>
377377
378 let commitCallCount = 0;378 let commitCallCount = 0;
379379
380 const mutation = client.defineBatched({380 const mutation = client.defineDebounced({
381 optimistic({ helpers }, amount: number) {381 optimistic({ helpers }, amount: number) {
382 helpers.increment("counter", amount);382 helpers.increment("counter", amount);
383 },383 },
...@@ -420,7 +420,7 @@ test("BatchMutation - skips commit when value unchanged", async () => {...@@ -420,7 +420,7 @@ test("BatchMutation - skips commit when value unchanged", async () => {
420420
421 let commitCallCount = 0;421 let commitCallCount = 0;
422422
423 const mutation = client.defineBatched({423 const mutation = client.defineDebounced({
424 optimistic({ helpers }, amount: number) {424 optimistic({ helpers }, amount: number) {
425 helpers.increment("counter", amount);425 helpers.increment("counter", amount);
426 },426 },
...@@ -479,7 +479,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => {...@@ -479,7 +479,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => {
479479
480 let commitCallCount = 0;480 let commitCallCount = 0;
481481
482 const mutation = client.defineBatched({482 const mutation = client.defineDebounced({
483 optimistic({ helpers }, count: number) {483 optimistic({ helpers }, count: number) {
484 helpers.setCount(count);484 helpers.setCount(count);
485 },485 },
...@@ -531,7 +531,7 @@ test("BatchMutation - custom deepEquals function", async () => {...@@ -531,7 +531,7 @@ test("BatchMutation - custom deepEquals function", async () => {
531 testStore.clear();531 testStore.clear();
532 testStore.set("counter", 0);532 testStore.set("counter", 0);
533533
534 const mutation = client.defineBatched({534 const mutation = client.defineDebounced({
535 optimistic({ helpers }, amount: number) {535 optimistic({ helpers }, amount: number) {
536 helpers.increment("counter", amount);536 helpers.increment("counter", amount);
537 },537 },
...@@ -546,7 +546,9 @@ test("BatchMutation - custom deepEquals function", async () => {...@@ -546,7 +546,9 @@ test("BatchMutation - custom deepEquals function", async () => {
546 async refetch() {},546 async refetch() {},
547 });547 });
548548
549 await mutation.runAndReturn(5);549 await mutation.runAndReturn(5).catch(() => {
550 // Expected to fail due to commit error
551 });
550 await delay(30);552 await delay(30);
551553
552 // Custom deepEquals should have been called554 // Custom deepEquals should have been called
...@@ -562,7 +564,7 @@ test("BatchMutation - rollback on commit error", async () => {...@@ -562,7 +564,7 @@ test("BatchMutation - rollback on commit error", async () => {
562 testStore.clear();564 testStore.clear();
563 testStore.set("counter", 10);565 testStore.set("counter", 10);
564566
565 const mutation = client.defineBatched({567 const mutation = client.defineDebounced({
566 optimistic({ helpers }, amount: number) {568 optimistic({ helpers }, amount: number) {
567 helpers.increment("counter", amount);569 helpers.increment("counter", amount);
568 },570 },
...@@ -594,7 +596,7 @@ test("BatchMutation - error event includes error details", async () => {...@@ -594,7 +596,7 @@ test("BatchMutation - error event includes error details", async () => {
594596
595 const tracker = createEventTracker<number>();597 const tracker = createEventTracker<number>();
596598
597 const mutation = client.defineBatched({599 const mutation = client.defineDebounced({
598 optimistic({ helpers }, amount: number) {600 optimistic({ helpers }, amount: number) {
599 helpers.increment("counter", amount);601 helpers.increment("counter", amount);
600 },602 },
...@@ -629,7 +631,7 @@ test("BatchMutation - key() returns JSON stringified key", () => {...@@ -629,7 +631,7 @@ test("BatchMutation - key() returns JSON stringified key", () => {
629 const { client } = createTestClient();631 const { client } = createTestClient();
630 testStore.clear();632 testStore.clear();
631633
632 const mutation = client.defineBatched({634 const mutation = client.defineDebounced({
633 optimistic(_ctx, _id: string) {},635 optimistic(_ctx, _id: string) {},
634 mode: "debounce",636 mode: "debounce",
635 time: 20,637 time: 20,
...@@ -649,7 +651,7 @@ test("BatchMutation - key() can return array", () => {...@@ -649,7 +651,7 @@ test("BatchMutation - key() can return array", () => {
649 const { client } = createTestClient();651 const { client } = createTestClient();
650 testStore.clear();652 testStore.clear();
651653
652 const mutation = client.defineBatched({654 const mutation = client.defineDebounced({
653 optimistic(_ctx, _id: string) {},655 optimistic(_ctx, _id: string) {},
654 mode: "debounce",656 mode: "debounce",
655 time: 20,657 time: 20,
...@@ -676,7 +678,7 @@ test("BatchMutation - different keys create separate batches", async () => {...@@ -676,7 +678,7 @@ test("BatchMutation - different keys create separate batches", async () => {
676678
677 let commitCallCount = 0;679 let commitCallCount = 0;
678680
679 const mutation = client.defineBatched({681 const mutation = client.defineDebounced({
680 optimistic({ helpers }, key: string, amount: number) {682 optimistic({ helpers }, key: string, amount: number) {
681 helpers.increment(`counter-${key}`, amount);683 helpers.increment(`counter-${key}`, amount);
682 },684 },
...@@ -713,7 +715,7 @@ test("BatchMutation - describe() with string", () => {...@@ -713,7 +715,7 @@ test("BatchMutation - describe() with string", () => {
713 const { client } = createTestClient();715 const { client } = createTestClient();
714 testStore.clear();716 testStore.clear();
715717
716 const mutation = client.defineBatched({718 const mutation = client.defineDebounced({
717 optimistic(_ctx, _amount: number) {},719 optimistic(_ctx, _amount: number) {},
718 mode: "debounce",720 mode: "debounce",
719 time: 20,721 time: 20,
...@@ -733,7 +735,7 @@ test("BatchMutation - describe() with function", () => {...@@ -733,7 +735,7 @@ test("BatchMutation - describe() with function", () => {
733 const { client } = createTestClient();735 const { client } = createTestClient();
734 testStore.clear();736 testStore.clear();
735737
736 const mutation = client.defineBatched({738 const mutation = client.defineDebounced({
737 optimistic(_ctx, _amount: number) {},739 optimistic(_ctx, _amount: number) {},
738 mode: "debounce",740 mode: "debounce",
739 time: 20,741 time: 20,
...@@ -758,7 +760,7 @@ test("BatchMutation - all pending promises resolve with same result", async () =...@@ -758,7 +760,7 @@ test("BatchMutation - all pending promises resolve with same result", async () =
758 testStore.clear();760 testStore.clear();
759 testStore.set("counter", 0);761 testStore.set("counter", 0);
760762
761 const mutation = client.defineBatched({763 const mutation = client.defineDebounced({
762 optimistic({ helpers }, amount: number) {764 optimistic({ helpers }, amount: number) {
763 helpers.increment("counter", amount);765 helpers.increment("counter", amount);
764 },766 },
...@@ -794,7 +796,7 @@ test("BatchMutation - all pending promises reject with same error", async () =>...@@ -794,7 +796,7 @@ test("BatchMutation - all pending promises reject with same error", async () =>
794 testStore.clear();796 testStore.clear();
795 testStore.set("counter", 0);797 testStore.set("counter", 0);
796798
797 const mutation = client.defineBatched({799 const mutation = client.defineDebounced({
798 optimistic({ helpers }, amount: number) {800 optimistic({ helpers }, amount: number) {
799 helpers.increment("counter", amount);801 helpers.increment("counter", amount);
800 },802 },
...@@ -837,7 +839,7 @@ test("BatchMutation - handles empty getValue result", async () => {...@@ -837,7 +839,7 @@ test("BatchMutation - handles empty getValue result", async () => {
837839
838 let commitCallCount = 0;840 let commitCallCount = 0;
839841
840 const mutation = client.defineBatched({842 const mutation = client.defineDebounced({
841 optimistic({ helpers }, amount: number) {843 optimistic({ helpers }, amount: number) {
842 helpers.setValue("nonexistent", amount);844 helpers.setValue("nonexistent", amount);
843 },845 },
...@@ -865,7 +867,7 @@ test("BatchMutation - channel cleanup after idle with no listeners", async () =>...@@ -865,7 +867,7 @@ test("BatchMutation - channel cleanup after idle with no listeners", async () =>
865 testStore.clear();867 testStore.clear();
866 testStore.set("counter", 0);868 testStore.set("counter", 0);
867869
868 const mutation = client.defineBatched({870 const mutation = client.defineDebounced({
869 optimistic({ helpers }, amount: number) {871 optimistic({ helpers }, amount: number) {
870 helpers.increment("counter", amount);872 helpers.increment("counter", amount);
871 },873 },
...@@ -900,7 +902,7 @@ test("BatchMutation - default time is 200ms", async () => {...@@ -900,7 +902,7 @@ test("BatchMutation - default time is 200ms", async () => {
900 let commitTime: number | null = null;902 let commitTime: number | null = null;
901 const startTime = Date.now();903 const startTime = Date.now();
902904
903 const mutation = client.defineBatched({905 const mutation = client.defineDebounced({
904 optimistic({ helpers }, amount: number) {906 optimistic({ helpers }, amount: number) {
905 helpers.increment("counter", amount);907 helpers.increment("counter", amount);
906 },908 },
...@@ -931,7 +933,7 @@ test("BatchMutation - context is passed to getValue", async () => {...@@ -931,7 +933,7 @@ test("BatchMutation - context is passed to getValue", async () => {
931933
932 let receivedUserId: string | undefined;934 let receivedUserId: string | undefined;
933935
934 const mutation = client.defineBatched({936 const mutation = client.defineDebounced({
935 optimistic({ helpers }, amount: number) {937 optimistic({ helpers }, amount: number) {
936 helpers.increment("counter", amount);938 helpers.increment("counter", amount);
937 },939 },
...@@ -962,7 +964,7 @@ test("BatchMutation - context is passed to commit", async () => {...@@ -962,7 +964,7 @@ test("BatchMutation - context is passed to commit", async () => {
962964
963 let receivedUserId: string | undefined;965 let receivedUserId: string | undefined;
964966
965 const mutation = client.defineBatched({967 const mutation = client.defineDebounced({
966 optimistic({ helpers }, amount: number) {968 optimistic({ helpers }, amount: number) {
967 helpers.increment("counter", amount);969 helpers.increment("counter", amount);
968 },970 },
...@@ -991,7 +993,7 @@ test("BatchMutation - first args are used for commit", async () => {...@@ -991,7 +993,7 @@ test("BatchMutation - first args are used for commit", async () => {
991993
992 let receivedArgs: [string, number] | undefined;994 let receivedArgs: [string, number] | undefined;
993995
994 const mutation = client.defineBatched({996 const mutation = client.defineDebounced({
995 optimistic({ helpers }, _label: string, amount: number) {997 optimistic({ helpers }, _label: string, amount: number) {
996 helpers.increment("counter", amount);998 helpers.increment("counter", amount);
997 },999 },
test/queued.test.ts+74-74
...@@ -45,8 +45,8 @@ test("QueuedMutation - basic mutation success", async () => {...@@ -45,8 +45,8 @@ test("QueuedMutation - basic mutation success", async () => {
45 let mutateCallCount = 0;45 let mutateCallCount = 0;
46 let refetchCallCount = 0;46 let refetchCallCount = 0;
4747
48 const mutation = client.defineQueued({48 const mutation = client.defineBlocking({
49 async mutate(_, value: string) {49 async mutate(value: string) {
50 mutateCallCount++;50 mutateCallCount++;
51 await delay(10);51 await delay(10);
52 return `result-${value}`;52 return `result-${value}`;
...@@ -73,8 +73,8 @@ test("QueuedMutation - basic mutation success", async () => {...@@ -73,8 +73,8 @@ test("QueuedMutation - basic mutation success", async () => {
73test("QueuedMutation - run() catches errors", async () => {73test("QueuedMutation - run() catches errors", async () => {
74 const { client, errors } = createTestClient();74 const { client, errors } = createTestClient();
7575
76 const mutation = client.defineQueued({76 const mutation = client.defineBlocking({
77 async mutate(_, _value: string) {77 async mutate(_value: string) {
78 throw new Error("mutation failed");78 throw new Error("mutation failed");
79 },79 },
80 describe: "failing mutation",80 describe: "failing mutation",
...@@ -92,8 +92,8 @@ test("QueuedMutation - run() catches errors", async () => {...@@ -92,8 +92,8 @@ test("QueuedMutation - run() catches errors", async () => {
92test("QueuedMutation - runAndReturn() rejects on error", async () => {92test("QueuedMutation - runAndReturn() rejects on error", async () => {
93 const { client } = createTestClient();93 const { client } = createTestClient();
9494
95 const mutation = client.defineQueued({95 const mutation = client.defineBlocking({
96 async mutate(_, _value: string) {96 async mutate(_value: string) {
97 throw new Error("mutation failed");97 throw new Error("mutation failed");
98 },98 },
99 describe: "failing mutation",99 describe: "failing mutation",
...@@ -112,8 +112,8 @@ test("QueuedMutation - optimistic updates are applied immediately", async () =>...@@ -112,8 +112,8 @@ test("QueuedMutation - optimistic updates are applied immediately", async () =>
112 const { client } = createTestClient();112 const { client } = createTestClient();
113 testStore.clear();113 testStore.clear();
114114
115 const mutation = client.defineQueued({115 const mutation = client.defineBlocking({
116 async mutate(_, _key: string, value: string) {116 async mutate(_key: string, value: string) {
117 await delay(50);117 await delay(50);
118 return value;118 return value;
119 },119 },
...@@ -139,8 +139,8 @@ test("QueuedMutation - rollback on error", async () => {...@@ -139,8 +139,8 @@ test("QueuedMutation - rollback on error", async () => {
139 const { client } = createTestClient();139 const { client } = createTestClient();
140 testStore.clear();140 testStore.clear();
141141
142 const mutation = client.defineQueued({142 const mutation = client.defineBlocking({
143 async mutate(_, _key: string, _value: string) {143 async mutate(_key: string, _value: string) {
144 await delay(10);144 await delay(10);
145 throw new Error("mutation failed");145 throw new Error("mutation failed");
146 },146 },
...@@ -162,8 +162,8 @@ test("QueuedMutation - onSuccess callback is called", async () => {...@@ -162,8 +162,8 @@ test("QueuedMutation - onSuccess callback is called", async () => {
162 const { client } = createTestClient();162 const { client } = createTestClient();
163 const successResults: string[] = [];163 const successResults: string[] = [];
164164
165 const mutation = client.defineQueued({165 const mutation = client.defineBlocking({
166 async mutate(_, value: string) {166 async mutate(value: string) {
167 return `result-${value}`;167 return `result-${value}`;
168 },168 },
169 describe: "test mutation",169 describe: "test mutation",
...@@ -184,8 +184,8 @@ test("QueuedMutation - mutations with same key execute serially", async () => {...@@ -184,8 +184,8 @@ test("QueuedMutation - mutations with same key execute serially", async () => {
184 const { client } = createTestClient();184 const { client } = createTestClient();
185 const executionOrder: string[] = [];185 const executionOrder: string[] = [];
186186
187 const mutation = client.defineQueued({187 const mutation = client.defineBlocking({
188 async mutate(_, id: string) {188 async mutate(id: string) {
189 executionOrder.push(`start-${id}`);189 executionOrder.push(`start-${id}`);
190 await delay(20);190 await delay(20);
191 executionOrder.push(`end-${id}`);191 executionOrder.push(`end-${id}`);
...@@ -215,8 +215,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async...@@ -215,8 +215,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async
215 const { client } = createTestClient();215 const { client } = createTestClient();
216 const executionOrder: string[] = [];216 const executionOrder: string[] = [];
217217
218 const mutation = client.defineQueued({218 const mutation = client.defineBlocking({
219 async mutate(_, id: string) {219 async mutate(id: string) {
220 executionOrder.push(`start-${id}`);220 executionOrder.push(`start-${id}`);
221 await delay(20);221 await delay(20);
222 executionOrder.push(`end-${id}`);222 executionOrder.push(`end-${id}`);
...@@ -244,8 +244,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async...@@ -244,8 +244,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async
244test("QueuedMutation - key() returns JSON stringified key", () => {244test("QueuedMutation - key() returns JSON stringified key", () => {
245 const { client } = createTestClient();245 const { client } = createTestClient();
246246
247 const mutation = client.defineQueued({247 const mutation = client.defineBlocking({
248 async mutate(_, id: string) {248 async mutate(id: string) {
249 return id;249 return id;
250 },250 },
251 describe: "test mutation",251 describe: "test mutation",
...@@ -263,8 +263,8 @@ test("QueuedMutation - key() returns JSON stringified key", () => {...@@ -263,8 +263,8 @@ test("QueuedMutation - key() returns JSON stringified key", () => {
263test("QueuedMutation - key() defaults to 'shared' when no key function", () => {263test("QueuedMutation - key() defaults to 'shared' when no key function", () => {
264 const { client } = createTestClient();264 const { client } = createTestClient();
265265
266 const mutation = client.defineQueued({266 const mutation = client.defineBlocking({
267 async mutate(_, id: string) {267 async mutate(id: string) {
268 return id;268 return id;
269 },269 },
270 describe: "test mutation",270 describe: "test mutation",
...@@ -278,8 +278,8 @@ test("QueuedMutation - key() defaults to 'shared' when no key function", () => {...@@ -278,8 +278,8 @@ test("QueuedMutation - key() defaults to 'shared' when no key function", () => {
278test("QueuedMutation - key() can return array", () => {278test("QueuedMutation - key() can return array", () => {
279 const { client } = createTestClient();279 const { client } = createTestClient();
280280
281 const mutation = client.defineQueued({281 const mutation = client.defineBlocking({
282 async mutate(_, _userId: string, _itemId: string) {282 async mutate(_userId: string, _itemId: string) {
283 return "result";283 return "result";
284 },284 },
285 describe: "test mutation",285 describe: "test mutation",
...@@ -300,8 +300,8 @@ test("QueuedMutation - key() can return array", () => {...@@ -300,8 +300,8 @@ test("QueuedMutation - key() can return array", () => {
300test("QueuedMutation - describe() with string", () => {300test("QueuedMutation - describe() with string", () => {
301 const { client } = createTestClient();301 const { client } = createTestClient();
302302
303 const mutation = client.defineQueued({303 const mutation = client.defineBlocking({
304 async mutate(_, value: string) {304 async mutate(value: string) {
305 return value;305 return value;
306 },306 },
307 describe: "create item",307 describe: "create item",
...@@ -315,8 +315,8 @@ test("QueuedMutation - describe() with string", () => {...@@ -315,8 +315,8 @@ test("QueuedMutation - describe() with string", () => {
315test("QueuedMutation - describe() with function", () => {315test("QueuedMutation - describe() with function", () => {
316 const { client } = createTestClient();316 const { client } = createTestClient();
317317
318 const mutation = client.defineQueued({318 const mutation = client.defineBlocking({
319 async mutate(_, id: string) {319 async mutate(id: string) {
320 return id;320 return id;
321 },321 },
322 describe({ args }) {322 describe({ args }) {
...@@ -333,8 +333,8 @@ test("QueuedMutation - describe() with function", () => {...@@ -333,8 +333,8 @@ test("QueuedMutation - describe() with function", () => {
333test("QueuedMutation - describe() receives context", () => {333test("QueuedMutation - describe() receives context", () => {
334 const { client } = createTestClient();334 const { client } = createTestClient();
335335
336 const mutation = client.defineQueued({336 const mutation = client.defineBlocking({
337 async mutate(_, id: string) {337 async mutate(id: string) {
338 return id;338 return id;
339 },339 },
340 describe({ userId, args }) {340 describe({ userId, args }) {
...@@ -355,8 +355,8 @@ test("QueuedMutation - subscribe() tracks mutation events", async () => {...@@ -355,8 +355,8 @@ test("QueuedMutation - subscribe() tracks mutation events", async () => {
355 const { client } = createTestClient();355 const { client } = createTestClient();
356 const tracker = createEventTracker<string>();356 const tracker = createEventTracker<string>();
357357
358 const mutation = client.defineQueued({358 const mutation = client.defineBlocking({
359 async mutate(_, value: string) {359 async mutate(value: string) {
360 await delay(10);360 await delay(10);
361 return `result-${value}`;361 return `result-${value}`;
362 },362 },
...@@ -384,8 +384,8 @@ test("QueuedMutation - unsubscribe stops receiving events", async () => {...@@ -384,8 +384,8 @@ test("QueuedMutation - unsubscribe stops receiving events", async () => {
384 const { client } = createTestClient();384 const { client } = createTestClient();
385 const tracker = createEventTracker<string>();385 const tracker = createEventTracker<string>();
386386
387 const mutation = client.defineQueued({387 const mutation = client.defineBlocking({
388 async mutate(_, value: string) {388 async mutate(value: string) {
389 await delay(10);389 await delay(10);
390 return value;390 return value;
391 },391 },
...@@ -411,8 +411,8 @@ test("QueuedMutation - refetchOnSuccess can be disabled", async () => {...@@ -411,8 +411,8 @@ test("QueuedMutation - refetchOnSuccess can be disabled", async () => {
411 const { client } = createTestClient();411 const { client } = createTestClient();
412 let refetchCallCount = 0;412 let refetchCallCount = 0;
413413
414 const mutation = client.defineQueued({414 const mutation = client.defineBlocking({
415 async mutate(_, _value: string) {415 async mutate(_value: string) {
416 return _value;416 return _value;
417 },417 },
418 describe: "test mutation",418 describe: "test mutation",
...@@ -432,8 +432,8 @@ test("QueuedMutation - refetch is called on error", async () => {...@@ -432,8 +432,8 @@ test("QueuedMutation - refetch is called on error", async () => {
432 const { client } = createTestClient();432 const { client } = createTestClient();
433 let refetchCallCount = 0;433 let refetchCallCount = 0;
434434
435 const mutation = client.defineQueued({435 const mutation = client.defineBlocking({
436 async mutate(_, _value: string) {436 async mutate(_value: string) {
437 throw new Error("mutation failed");437 throw new Error("mutation failed");
438 },438 },
439 describe: "failing mutation",439 describe: "failing mutation",
...@@ -452,8 +452,8 @@ test("QueuedMutation - queued mutations are cancelled on error", async () => {...@@ -452,8 +452,8 @@ test("QueuedMutation - queued mutations are cancelled on error", async () => {
452 const { client } = createTestClient();452 const { client } = createTestClient();
453 const executionOrder: string[] = [];453 const executionOrder: string[] = [];
454454
455 const mutation = client.defineQueued({455 const mutation = client.defineBlocking({
456 async mutate(_, id: string) {456 async mutate(id: string) {
457 executionOrder.push(`start-${id}`);457 executionOrder.push(`start-${id}`);
458 await delay(10);458 await delay(10);
459 if (id === "1") {459 if (id === "1") {
...@@ -486,8 +486,8 @@ test("QueuedMutation - rollbacks are called in reverse order on error", async ()...@@ -486,8 +486,8 @@ test("QueuedMutation - rollbacks are called in reverse order on error", async ()
486 const { client } = createTestClient();486 const { client } = createTestClient();
487 const rollbackOrder: number[] = [];487 const rollbackOrder: number[] = [];
488488
489 const mutation = client.defineQueued({489 const mutation = client.defineBlocking({
490 async mutate(_, _value: string) {490 async mutate(_value: string) {
491 throw new Error("mutation failed");491 throw new Error("mutation failed");
492 },492 },
493 describe: "failing mutation",493 describe: "failing mutation",
...@@ -509,8 +509,8 @@ test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation...@@ -509,8 +509,8 @@ test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation
509 const { client } = createTestClient();509 const { client } = createTestClient();
510 const rollbackOrder: string[] = [];510 const rollbackOrder: string[] = [];
511511
512 const mutation = client.defineQueued({512 const mutation = client.defineBlocking({
513 async mutate(_, id: string) {513 async mutate(id: string) {
514 await delay(10);514 await delay(10);
515 if (id === "fail") {515 if (id === "fail") {
516 throw new Error("mutation failed");516 throw new Error("mutation failed");
...@@ -542,8 +542,8 @@ test("QueuedMutation - onRestore throws error if called after optimistic phase",...@@ -542,8 +542,8 @@ test("QueuedMutation - onRestore throws error if called after optimistic phase",
542 const { client } = createTestClient();542 const { client } = createTestClient();
543 let capturedOnRestore: ((cb: () => void) => void) | null = null;543 let capturedOnRestore: ((cb: () => void) => void) | null = null;
544544
545 const mutation = client.defineQueued({545 const mutation = client.defineBlocking({
546 async mutate(_, _value: string) {546 async mutate(_value: string) {
547 return "result";547 return "result";
548 },548 },
549 describe: "test mutation",549 describe: "test mutation",
...@@ -573,8 +573,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase",...@@ -573,8 +573,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase",
573 const { client } = createTestClient();573 const { client } = createTestClient();
574 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;574 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
575575
576 const mutation = client.defineQueued({576 const mutation = client.defineBlocking({
577 async mutate(_, _value: string) {577 async mutate(_value: string) {
578 return "result";578 return "result";
579 },579 },
580 describe: "test mutation",580 describe: "test mutation",
...@@ -603,8 +603,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase",...@@ -603,8 +603,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase",
603test("QueuedMutation - error during optimistic update is rejected immediately", async () => {603test("QueuedMutation - error during optimistic update is rejected immediately", async () => {
604 const { client } = createTestClient();604 const { client } = createTestClient();
605605
606 const mutation = client.defineQueued({606 const mutation = client.defineBlocking({
607 async mutate(_, _value: string) {607 async mutate(_value: string) {
608 return "result";608 return "result";
609 },609 },
610 describe: "test mutation",610 describe: "test mutation",
...@@ -625,8 +625,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call...@@ -625,8 +625,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call
625 const { client } = createTestClient();625 const { client } = createTestClient();
626 const rollbackOrder: number[] = [];626 const rollbackOrder: number[] = [];
627627
628 const mutation = client.defineQueued({628 const mutation = client.defineBlocking({
629 async mutate(_, _value: string) {629 async mutate(_value: string) {
630 return "result";630 return "result";
631 },631 },
632 describe: "test mutation",632 describe: "test mutation",
...@@ -648,8 +648,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call...@@ -648,8 +648,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call
648test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => {648test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => {
649 const { client, errors } = createTestClient();649 const { client, errors } = createTestClient();
650650
651 const mutation = client.defineQueued({651 const mutation = client.defineBlocking({
652 async mutate(_, value: string) {652 async mutate(value: string) {
653 return value;653 return value;
654 },654 },
655 describe: "test mutation",655 describe: "test mutation",
...@@ -674,8 +674,8 @@ test("QueuedMutation - optimistic function receives args and helpers", async ()...@@ -674,8 +674,8 @@ test("QueuedMutation - optimistic function receives args and helpers", async ()
674 let receivedArgs: unknown[] | undefined;674 let receivedArgs: unknown[] | undefined;
675 let receivedHelpers: unknown | undefined;675 let receivedHelpers: unknown | undefined;
676676
677 const mutation = client.defineQueued({677 const mutation = client.defineBlocking({
678 async mutate(_, _value: string) {678 async mutate(_value: string) {
679 return "result";679 return "result";
680 },680 },
681 describe: "test mutation",681 describe: "test mutation",
...@@ -697,8 +697,8 @@ test("QueuedMutation - refetch receives context and args", async () => {...@@ -697,8 +697,8 @@ test("QueuedMutation - refetch receives context and args", async () => {
697 let receivedUserId: string | undefined;697 let receivedUserId: string | undefined;
698 let receivedArgs: unknown[] | undefined;698 let receivedArgs: unknown[] | undefined;
699699
700 const mutation = client.defineQueued({700 const mutation = client.defineBlocking({
701 async mutate(_, _id: string, value: string) {701 async mutate(_id: string, value: string) {
702 return value;702 return value;
703 },703 },
704 describe: "test mutation",704 describe: "test mutation",
...@@ -719,8 +719,8 @@ test("QueuedMutation - notifies error on mutation failure", async () => {...@@ -719,8 +719,8 @@ test("QueuedMutation - notifies error on mutation failure", async () => {
719 const { client } = createTestClient();719 const { client } = createTestClient();
720 const tracker = createEventTracker<string>();720 const tracker = createEventTracker<string>();
721721
722 const mutation = client.defineQueued({722 const mutation = client.defineBlocking({
723 async mutate(_, _value: string) {723 async mutate(_value: string) {
724 await delay(10);724 await delay(10);
725 throw new Error("mutation failed");725 throw new Error("mutation failed");
726 },726 },
...@@ -747,8 +747,8 @@ test("QueuedMutation - multiple subscribers receive events", async () => {...@@ -747,8 +747,8 @@ test("QueuedMutation - multiple subscribers receive events", async () => {
747 const tracker1 = createEventTracker<string>();747 const tracker1 = createEventTracker<string>();
748 const tracker2 = createEventTracker<string>();748 const tracker2 = createEventTracker<string>();
749749
750 const mutation = client.defineQueued({750 const mutation = client.defineBlocking({
751 async mutate(_, value: string) {751 async mutate(value: string) {
752 await delay(5);752 await delay(5);
753 return value;753 return value;
754 },754 },
...@@ -774,8 +774,8 @@ test("QueuedMutation - onSuccess is called before mutation resolves", async () =...@@ -774,8 +774,8 @@ test("QueuedMutation - onSuccess is called before mutation resolves", async () =
774 const { client } = createTestClient();774 const { client } = createTestClient();
775 const callOrder: string[] = [];775 const callOrder: string[] = [];
776776
777 const mutation = client.defineQueued({777 const mutation = client.defineBlocking({
778 async mutate(_, value: string) {778 async mutate(value: string) {
779 return value;779 return value;
780 },780 },
781 describe: "test mutation",781 describe: "test mutation",
...@@ -804,8 +804,8 @@ test("QueuedMutation - result is passed to notification on success", async () =>...@@ -804,8 +804,8 @@ test("QueuedMutation - result is passed to notification on success", async () =>
804 const { client } = createTestClient();804 const { client } = createTestClient();
805 const tracker = createEventTracker<string>();805 const tracker = createEventTracker<string>();
806806
807 const mutation = client.defineQueued({807 const mutation = client.defineBlocking({
808 async mutate(_, value: string) {808 async mutate(value: string) {
809 await delay(5);809 await delay(5);
810 return `result-${value}`;810 return `result-${value}`;
811 },811 },
...@@ -834,8 +834,8 @@ test("QueuedMutation - channel is reused for same key", async () => {...@@ -834,8 +834,8 @@ test("QueuedMutation - channel is reused for same key", async () => {
834 const { client } = createTestClient();834 const { client } = createTestClient();
835 const events: string[] = [];835 const events: string[] = [];
836836
837 const mutation = client.defineQueued({837 const mutation = client.defineBlocking({
838 async mutate(_, value: string) {838 async mutate(value: string) {
839 events.push(`mutate-${value}`);839 events.push(`mutate-${value}`);
840 return value;840 return value;
841 },841 },
...@@ -859,8 +859,8 @@ test("QueuedMutation - channel is reused for same key", async () => {...@@ -859,8 +859,8 @@ test("QueuedMutation - channel is reused for same key", async () => {
859test("QueuedMutation - empty queue after all mutations complete", async () => {859test("QueuedMutation - empty queue after all mutations complete", async () => {
860 const { client } = createTestClient();860 const { client } = createTestClient();
861861
862 const mutation = client.defineQueued({862 const mutation = client.defineBlocking({
863 async mutate(_, value: string) {863 async mutate(value: string) {
864 await delay(5);864 await delay(5);
865 return value;865 return value;
866 },866 },
...@@ -893,8 +893,8 @@ test("QueuedMutation - multiple onSuccess callbacks are all called", async () =>...@@ -893,8 +893,8 @@ test("QueuedMutation - multiple onSuccess callbacks are all called", async () =>
893 const { client } = createTestClient();893 const { client } = createTestClient();
894 const results: string[] = [];894 const results: string[] = [];
895895
896 const mutation = client.defineQueued({896 const mutation = client.defineBlocking({
897 async mutate(_, value: string) {897 async mutate(value: string) {
898 return value;898 return value;
899 },899 },
900 describe: "test mutation",900 describe: "test mutation",
...@@ -916,8 +916,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {...@@ -916,8 +916,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {
916 const { client } = createTestClient();916 const { client } = createTestClient();
917 let refetchCalled = false;917 let refetchCalled = false;
918918
919 const mutation = client.defineQueued({919 const mutation = client.defineBlocking({
920 async mutate(_, value: string) {920 async mutate(value: string) {
921 return value;921 return value;
922 },922 },
923 describe: "test mutation",923 describe: "test mutation",
...@@ -938,8 +938,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {...@@ -938,8 +938,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {
938test("QueuedMutation - refetch error after mutation failure is reported", async () => {938test("QueuedMutation - refetch error after mutation failure is reported", async () => {
939 const { client, errors } = createTestClient();939 const { client, errors } = createTestClient();
940940
941 const mutation = client.defineQueued({941 const mutation = client.defineBlocking({
942 async mutate(_, _value: string) {942 async mutate(_value: string) {
943 throw new Error("mutation failed");943 throw new Error("mutation failed");
944 },944 },
945 describe: "failing mutation",945 describe: "failing mutation",