authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:33:50-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:37:58-08:00
log1ea8a97377ab12b84a6d111aa16a911898376d94
tree8d8f5ebdd29e6b02f1c3ef015630eaeb544fe9fc
parentcd0b36c24e6342b64f505e21d51dec1fb4a72199
signaturelock-open Commit is signed but in an unrecognized format.

some more test cases


8 files changed, 362 insertions(+), 93 deletions(-)

jsr.json+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.12",3 "version": "1.0.0-beta.13",
4 "exports": {4 "exports": {
5 ".": "./src/mod.ts",5 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",6 "./tanstack-query.ts": "./src/tanstack-query.ts",
src/mutation.ts+3-3
...@@ -229,7 +229,7 @@ export class BlockingMutation<...@@ -229,7 +229,7 @@ export class BlockingMutation<
229 }229 }
230230
231 /** Calls the mutation with custom handlers that can suppress global handlers. */231 /** Calls the mutation with custom handlers that can suppress global handlers. */
232 runWithOptions(...array: [...Args, RunOptions<Result>]): Promise<Result> {232 runWithOptions(...array: [...Args, RunOptions<Result>]): void {
233 if (!this.#client.enabled) {233 if (!this.#client.enabled) {
234 throw new Error(234 throw new Error(
235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
...@@ -266,8 +266,6 @@ export class BlockingMutation<...@@ -266,8 +266,6 @@ export class BlockingMutation<
266 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);266 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);
267 }267 }
268 });268 });
269
270 return promise;
271 }269 }
272270
273 /** Calls the mutation, treating the errors as promise rejection. */271 /** Calls the mutation, treating the errors as promise rejection. */
...@@ -415,6 +413,8 @@ export class BlockingMutation<...@@ -415,6 +413,8 @@ export class BlockingMutation<
415 } else {413 } else {
416 // Discard refetch callbacks if refetchOnSuccess is false414 // Discard refetch callbacks if refetchOnSuccess is false
417 channel.refetches = [];415 channel.refetches = [];
416 // Notify listeners with success status and result before moving to next
417 this.#notify(channel, "mutating", result);
418 this.#executeNext(key, channel);418 this.#executeNext(key, channel);
419 }419 }
420 resolve(result);420 resolve(result);
src/react.ts+10-3
...@@ -45,7 +45,7 @@ export interface UseMutateResultBase<Args extends unknown[], Result> {...@@ -45,7 +45,7 @@ export interface UseMutateResultBase<Args extends unknown[], Result> {
45 run: (...args: Args) => void;45 run: (...args: Args) => void;
46 runWithOptions: (46 runWithOptions: (
47 ..._: [...args: Args, options: RunOptions<Result>]47 ..._: [...args: Args, options: RunOptions<Result>]
48 ) => Promise<Result>;48 ) => void;
49 clear: () => void;49 clear: () => void;
50 setError: (error: unknown) => void;50 setError: (error: unknown) => void;
51 args: Args | undefined;51 args: Args | undefined;
...@@ -89,6 +89,8 @@ export interface UseMutateIdle {...@@ -89,6 +89,8 @@ export interface UseMutateIdle {
89 result: undefined;89 result: undefined;
90 error: undefined;90 error: undefined;
91 errorMessage: undefined;91 errorMessage: undefined;
92 /** `true` when controls should be disabled */
93 isDisabled: boolean;
92 /** `true` when a `mutate` function is currently running. */94 /** `true` when a `mutate` function is currently running. */
93 isMutating: boolean;95 isMutating: boolean;
94 /** `true` when a loading indicator should be shown. */96 /** `true` when a loading indicator should be shown. */
...@@ -314,8 +316,8 @@ class Observer<Args extends unknown[], Result> {...@@ -314,8 +316,8 @@ class Observer<Args extends unknown[], Result> {
314 );316 );
315 }317 }
316318
317 // Delegate to the mutation's runWithOptions319 // Delegate to the mutation's runWithOptions and return the promise
318 mutation.runWithOptions(...args, options);320 return mutation.runWithOptions(...args, options);
319 }321 }
320322
321 binding: UseMutateResult<Args, Result> = ((self: this) => ({323 binding: UseMutateResult<Args, Result> = ((self: this) => ({
...@@ -363,6 +365,11 @@ class Observer<Args extends unknown[], Result> {...@@ -363,6 +365,11 @@ class Observer<Args extends unknown[], Result> {
363 self.watched.add("isMutating");365 self.watched.add("isMutating");
364 return self.state.isMutating;366 return self.state.isMutating;
365 },367 },
368 // TODO: when auth drops this will be dependant on the auth status and isMutating
369 get isDisabled() {
370 self.watched.add("isMutating");
371 return self.state.isMutating;
372 },
366 get isPending() {373 get isPending() {
367 self.watched.add("isPending");374 self.watched.add("isPending");
368 return self.state.isPending;375 return self.state.isPending;
test/cases/runWithOptions.test.tsx created+61
...@@ -0,0 +1,61 @@
1import { useMutate } from "@clo/react-mutation";
2import { assertEquals } from "@std/assert";
3import { act, render, screen } from "@testing-library/react";
4import { userEvent } from "@testing-library/user-event";
5import { test, vi } from "vitest";
6import { createTestMutationClient, IterableStream } from "../share.ts";
7
8test("runWithOptions should allow react hook to do local handling", async () => {
9 vi.useFakeTimers({ shouldAdvanceTime: true });
10 const user = userEvent.setup({ delay: null });
11
12 const { client, successMessages, errorMessages } = createTestMutationClient();
13 const s = new IterableStream<string>();
14
15 const mutTest = client.define({
16 mutate: async () => {
17 return (await s.next()).value;
18 },
19 describe: "Test the action",
20 describeResult: "Tested the action",
21 optimistic: () => {},
22 refetchOnSuccess: false,
23 });
24
25 let renders: Array<{ status: string; result: string | undefined }> = [];
26 function TestComponent() {
27 const { runWithOptions, status, result } = useMutate(mutTest);
28 renders.push({ status, result });
29 return (
30 <button
31 data-testid="a"
32 onClick={() => {
33 runWithOptions({ onSuccessDataOnly: () => {} });
34 }}
35 >
36 button
37 </button>
38 );
39 }
40
41 render(<TestComponent />);
42 // initial state
43 assertEquals(renders, [{ status: "idle", result: undefined }]);
44 assertEquals(successMessages, []);
45 assertEquals(errorMessages, []);
46 renders = [];
47 vi.runAllTimers();
48
49 // mutation 1 - success
50 await act(() => user.click(screen.getByTestId("a")));
51 assertEquals(renders, [{ status: "mutating", result: undefined }]);
52 renders = [];
53 await act(async () => {
54 s.push("ok");
55 vi.advanceTimersByTime(100);
56 });
57 assertEquals(successMessages, ["Tested the action"]);
58 assertEquals(errorMessages, []);
59 assertEquals(renders, [{ status: "success", result: "ok" }]);
60 renders = [];
61});
test/cases/setError.test.tsx created+260
...@@ -0,0 +1,260 @@
1import { useMutate } from "@clo/react-mutation";
2import { assertEquals } from "@std/assert";
3import { act, render, screen } from "@testing-library/react";
4import { userEvent } from "@testing-library/user-event";
5import { test, vi } from "vitest";
6import { createTestMutationClient, IterableStream } from "../share.ts";
7
8test("setError should manually set error state on the hook", async () => {
9 vi.useFakeTimers({ shouldAdvanceTime: true });
10 const user = userEvent.setup({ delay: null });
11
12 const { client, successMessages, errorMessages } = createTestMutationClient();
13
14 const mutTest = client.define({
15 mutate: async () => {
16 return "success";
17 },
18 describe: "Test the action",
19 describeResult: "Tested the action",
20 optimistic: () => {},
21 });
22
23 const manualError = new Error("Manual error");
24
25 let renders: Array<{
26 status: string;
27 result: string | undefined;
28 error: unknown;
29 errorMessage: string | undefined;
30 isError: boolean;
31 isSuccess: boolean;
32 }> = [];
33
34 function TestComponent() {
35 const { setError, status, result, error, errorMessage, isError, isSuccess } = useMutate(
36 mutTest,
37 );
38 renders.push({ status, result, error, errorMessage, isError, isSuccess });
39 return (
40 <button
41 data-testid="set-error-btn"
42 onClick={() => {
43 setError(manualError);
44 }}
45 >
46 Set Error
47 </button>
48 );
49 }
50
51 render(<TestComponent />);
52
53 // initial state - idle
54 assertEquals(renders, [
55 {
56 status: "idle",
57 result: undefined,
58 error: undefined,
59 errorMessage: undefined,
60 isError: false,
61 isSuccess: false,
62 },
63 ]);
64 assertEquals(successMessages, []);
65 assertEquals(errorMessages, []);
66 renders = [];
67 vi.runAllTimers();
68
69 // manually set error using setError
70 await act(() => user.click(screen.getByTestId("set-error-btn")));
71 assertEquals(renders, [
72 {
73 status: "error",
74 result: undefined,
75 error: manualError,
76 errorMessage: "Manual error",
77 isError: true,
78 isSuccess: false,
79 },
80 ]);
81 // setError should not trigger global error/success handlers
82 assertEquals(successMessages, []);
83 assertEquals(errorMessages, []);
84 renders = [];
85});
86
87test("setError should override success state", async () => {
88 vi.useFakeTimers({ shouldAdvanceTime: true });
89 const user = userEvent.setup({ delay: null });
90
91 const { client, successMessages, errorMessages } = createTestMutationClient();
92 const s = new IterableStream<string>();
93
94 const mutTest = client.define({
95 mutate: async () => {
96 return (await s.next()).value;
97 },
98 describe: "Test the action",
99 describeResult: "Tested the action",
100 optimistic: () => {},
101 });
102
103 const customError = "Custom error message";
104
105 let renders: Array<{
106 status: string;
107 result: string | undefined;
108 error: unknown;
109 isError: boolean;
110 isSuccess: boolean;
111 }> = [];
112
113 function TestComponent() {
114 const { run, setError, status, result, error, isError, isSuccess } = useMutate(mutTest);
115 renders.push({ status, result, error, isError, isSuccess });
116 return (
117 <div>
118 <button
119 data-testid="run-btn"
120 onClick={() => {
121 run();
122 }}
123 >
124 Run
125 </button>
126 <button
127 data-testid="set-error-btn"
128 onClick={() => {
129 setError(customError);
130 }}
131 >
132 Set Error
133 </button>
134 </div>
135 );
136 }
137
138 render(<TestComponent />);
139
140 // initial state
141 assertEquals(renders, [
142 {
143 status: "idle",
144 result: undefined,
145 error: undefined,
146 isError: false,
147 isSuccess: false,
148 },
149 ]);
150 renders = [];
151 vi.runAllTimers();
152
153 // run mutation - should succeed
154 await act(() => user.click(screen.getByTestId("run-btn")));
155 assertEquals(renders, [
156 {
157 status: "mutating",
158 result: undefined,
159 error: undefined,
160 isError: false,
161 isSuccess: false,
162 },
163 ]);
164 renders = [];
165
166 await act(async () => {
167 s.push("success result");
168 vi.advanceTimersByTime(100);
169 });
170
171 // verify success state
172 assertEquals(renders, [
173 {
174 status: "success",
175 result: "success result",
176 error: undefined,
177 isError: false,
178 isSuccess: true,
179 },
180 ]);
181 assertEquals(successMessages, []);
182 assertEquals(errorMessages, []);
183 renders = [];
184
185 // now manually set error - should override success state
186 await act(() => user.click(screen.getByTestId("set-error-btn")));
187 assertEquals(renders, [
188 {
189 status: "error",
190 result: undefined,
191 error: customError,
192 isError: true,
193 isSuccess: false,
194 },
195 ]);
196 // setError should not trigger global error handler
197 assertEquals(successMessages, []);
198 assertEquals(errorMessages, []);
199 renders = [];
200});
201
202test("setError should work with different error types", async () => {
203 vi.useFakeTimers({ shouldAdvanceTime: true });
204 const user = userEvent.setup({ delay: null });
205
206 const { client } = createTestMutationClient();
207
208 const mutTest = client.define({
209 mutate: async () => {
210 return "success";
211 },
212 describe: "Test the action",
213 describeResult: null,
214 optimistic: () => {},
215 });
216
217 let lastErrorMessage: string | undefined;
218
219 function TestComponent() {
220 const { setError, errorMessage } = useMutate(mutTest);
221 lastErrorMessage = errorMessage;
222 return (
223 <div>
224 <button
225 data-testid="set-string-error"
226 onClick={() => setError("String error")}
227 >
228 String
229 </button>
230 <button
231 data-testid="set-error-object"
232 onClick={() => setError(new Error("Error object"))}
233 >
234 Error
235 </button>
236 <button
237 data-testid="set-number-error"
238 onClick={() => setError(42)}
239 >
240 Number
241 </button>
242 </div>
243 );
244 }
245
246 render(<TestComponent />);
247 vi.runAllTimers();
248
249 // Test string error
250 await act(() => user.click(screen.getByTestId("set-string-error")));
251 assertEquals(lastErrorMessage, "String error");
252
253 // Test Error object
254 await act(() => user.click(screen.getByTestId("set-error-object")));
255 assertEquals(lastErrorMessage, "Error object");
256
257 // Test number (should be converted to string)
258 await act(() => user.click(screen.getByTestId("set-number-error")));
259 assertEquals(lastErrorMessage, "42");
260});
test/useMutate.test.tsx+18-85
...@@ -211,6 +211,24 @@ test("useMutate - local error and success handling", async () => {...@@ -211,6 +211,24 @@ test("useMutate - local error and success handling", async () => {
211 }]);211 }]);
212 renders = [];212 renders = [];
213213
214 // clear state
215 vi.runAllTimers();
216 await act(() => user.click(screen.getByTestId("b")));
217 assertEquals(renders, [{
218 error: undefined,
219 errorMessage: undefined,
220 isError: false,
221 isMutating: false,
222 isOptimisticData: false,
223 isPending: false,
224 isSuccess: false,
225 result: undefined,
226 }]);
227 renders = [];
228 await act(() => user.click(screen.getByTestId("b")));
229 assertEquals(renders, []); // nothing changed
230 renders = [];
231
214 // mutation 2 - failure232 // mutation 2 - failure
215 await act(() => user.click(screen.getByTestId("a")));233 await act(() => user.click(screen.getByTestId("a")));
216 assertEquals(renders, [{234 assertEquals(renders, [{
...@@ -256,88 +274,3 @@ test("useMutate - local error and success handling", async () => {...@@ -256,88 +274,3 @@ test("useMutate - local error and success handling", async () => {
256 assertEquals(successMessages, []);274 assertEquals(successMessages, []);
257 assertEquals(errorMessages, []);275 assertEquals(errorMessages, []);
258});276});
259
260test("useMutate - global error and success handling", async () => {
261 vi.useFakeTimers({ shouldAdvanceTime: true });
262 const user = userEvent.setup({ delay: null });
263
264 const { client, successMessages, errorMessages } = createTestMutationClient();
265 const s = new IterableStream<string>();
266
267 const mutTest = client.define({
268 mutate: async () => {
269 return (await s.next()).value;
270 },
271 describe: "Test the action",
272 describeResult: "Tested the action",
273 optimistic: () => {},
274 });
275
276 let renders: Array<{ isMutating: boolean; isPending: boolean }> = [];
277 function TestComponent() {
278 const { run, isMutating, isPending } = useMutate(mutTest);
279 renders.push({ isMutating, isPending });
280
281 return (
282 <>
283 <button
284 data-testid="a"
285 onClick={() => {
286 run();
287 }}
288 >
289 button
290 </button>
291 </>
292 );
293 }
294
295 render(<TestComponent />);
296 // initial state
297 assertEquals(renders, [{ isMutating: false, isPending: false }]);
298 assertEquals(successMessages, []);
299 assertEquals(errorMessages, []);
300 renders = [];
301 vi.runAllTimers();
302
303 // mutation 1
304 await act(() => user.click(screen.getByTestId("a")));
305 assertEquals(renders, [{ isMutating: true, isPending: false }]);
306 renders = [];
307 await act(() => vi.advanceTimersByTime(150));
308 assertEquals(renders, []);
309 await act(() => vi.advanceTimersByTime(50));
310 assertEquals(renders, [{ isMutating: true, isPending: true }]);
311 renders = [];
312 assertEquals(successMessages, []);
313 assertEquals(errorMessages, []);
314 await act(async () => {
315 s.push("ok");
316 vi.advanceTimersByTime(100);
317 });
318 assertEquals(successMessages, ["Tested the action"]);
319 assertEquals(errorMessages, []);
320 assertEquals(renders, [{ isMutating: false, isPending: false }]);
321 renders = [];
322
323 // mutation 2
324 await act(() => user.click(screen.getByTestId("a")));
325 assertEquals(renders, [{ isMutating: true, isPending: false }]);
326 renders = [];
327 await act(() => vi.advanceTimersByTime(150));
328 assertEquals(renders, []);
329 await act(() => vi.advanceTimersByTime(50));
330 assertEquals(renders, [{ isMutating: true, isPending: true }]);
331 renders = [];
332 assertEquals(successMessages, ["Tested the action"]);
333 assertEquals(errorMessages, []);
334 const error1 = new Error("damn!");
335 await act(async () => {
336 s.throw(error1);
337 vi.advanceTimersByTime(100);
338 });
339 assertEquals(successMessages, ["Tested the action"]);
340 assertEquals(errorMessages, [
341 { error: error1, message: "Could not test the action: damn!" },
342 ]);
343});
tsconfig.json+4-1
...@@ -16,7 +16,10 @@...@@ -16,7 +16,10 @@
16 "allowImportingTsExtensions": true,16 "allowImportingTsExtensions": true,
17 "jsx": "react-jsx",17 "jsx": "react-jsx",
18 "verbatimModuleSyntax": true,18 "verbatimModuleSyntax": true,
19 "types": ["react"]19 "types": ["react"],
20 "paths": {
21 "@clo/react-mutation": ["./src/mod.ts"]
22 }
20 },23 },
21 "include": ["src/**/*", "test/**/*"],24 "include": ["src/**/*", "test/**/*"],
22 "exclude": ["node_modules"]25 "exclude": ["node_modules"]
vitest.config.ts+5
...@@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config";...@@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config";
33
4export default defineConfig({4export default defineConfig({
5 plugins: [react()],5 plugins: [react()],
6 resolve: {
7 alias: {
8 "@clo/react-mutation": import.meta.resolve("./src/mod.ts"),
9 },
10 },
6 test: {11 test: {
7 globals: true,12 globals: true,
8 // Use happy-dom for React tests, fallback to node for others13 // Use happy-dom for React tests, fallback to node for others