authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:04:56-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:05:21-08:00
logcd0b36c24e6342b64f505e21d51dec1fb4a72199
tree68876dada6f84621669e19b2bd071775cc710251
parent27b9bdd4d52caa1b142458c5dd1437c75b69390c
signaturelock-open Commit is signed but in an unrecognized format.

feat: do some new real tests


7 files changed, 479 insertions(+), 18 deletions(-)

jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.11",
3 "version": "1.0.0-beta.12",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query.ts": "./src/tanstack-query.ts",
src/client.ts-1
......@@ -1,4 +1,3 @@
1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
21import { BlockingMutation, type MutationOptions } from "./mutation.ts";
32import type { Mutation } from "./types.ts";
43
src/mutation.ts+20-7
......@@ -27,7 +27,7 @@ export interface MutationOptions<
2727 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
2828 /**
2929 * Used in error messages and debug tools.
30 * Phrase it considering the template `Failed to ${describe(...)}`
30 * Phrase it considering the template `Could not ${describe(...)}`
3131 */
3232 describe: string | ((context: Config["context"] & { args: Args }) => string);
3333 /**
......@@ -263,8 +263,7 @@ export class BlockingMutation<
263263
264264 // Call global handler unless suppressed
265265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
267 this.#client.reportError(message, error);
266 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);
268267 }
269268 });
270269
......@@ -405,7 +404,7 @@ export class BlockingMutation<
405404 // Report any errors from refetch or callbacks
406405 results.forEach((result) => {
407406 if (result.status === "rejected") {
408 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
407 const message = `Failed to refetch data: ${errMessage(result.reason)}`;
409408 this.#client.reportError(message, result.reason);
410409 }
411410 });
......@@ -442,7 +441,7 @@ export class BlockingMutation<
442441 // Report any errors from refetch or callbacks
443442 results.forEach((result) => {
444443 if (result.status === "rejected") {
445 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
444 const message = `Failed to refetch data: ${errMessage(result.reason)}`;
446445 this.#client.reportError(message, result.reason);
447446 }
448447 });
......@@ -638,8 +637,13 @@ export class BlockingMutation<
638637 // Check if there are any listeners at execution time
639638 const hasListeners = channel.listeners.size > 0;
640639 if (!hasListeners) {
641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
642 this.#client.reportError(message, error);
640 this.#client.reportError(
641 formatFriendlyError(
642 this.describe(...args),
643 error,
644 ),
645 error,
646 );
643647 }
644648 },
645649 );
......@@ -660,3 +664,12 @@ export class BlockingMutation<
660664 // Otherwise, it will execute when the current item finishes
661665 }
662666}
667
668export function formatFriendlyError(
669 description: string | null,
670 error: unknown,
671) {
672 if (!description || !description[0]) return `Internal Error: ${errMessage(error)}`;
673 description = description[0].toLowerCase() + description.slice(1);
674 return `Could not ${description}: ${errMessage(error)}`;
675}
src/react.ts+30-9
......@@ -1,4 +1,5 @@
11import { message as errMessage } from "@clo/lib/error.ts";
2import type { Timer } from "@clo/lib/ts.ts";
23import {
34 type FC,
45 type MouseEvent,
......@@ -9,6 +10,7 @@ import {
910 useState,
1011} from "react";
1112import { jsx } from "react/jsx-runtime";
13import { formatFriendlyError } from "./mutation.ts";
1214import type { Mutation, RunOptions } from "./types.ts";
1315
1416/**
......@@ -69,7 +71,7 @@ export interface UseMutateError {
6971 status: "error";
7072 result: undefined;
7173 error: unknown;
72 /** User-friendly in this format: `Failed to {action}: {details}` */
74 /** User-friendly in this format: `Could not {action}: {details}` */
7375 errorMessage: string;
7476 /** `true` when a `mutate` function is currently running. */
7577 isMutating: false;
......@@ -138,6 +140,7 @@ class Observer<Args extends unknown[], Result> {
138140 mutation: Mutation<Args, Result> | null = null;
139141 unsubscribe: (() => void) | null = null;
140142 currentKey: string | null = null;
143 pendingTimer: Timer | null = null;
141144
142145 constructor(setRerender: (fn: number) => void) {
143146 this.setRerender = setRerender;
......@@ -166,11 +169,19 @@ class Observer<Args extends unknown[], Result> {
166169 this.state = initialState();
167170 }
168171
172 resetPending() {
173 this.setState({ isPending: false });
174 if (this.pendingTimer) clearTimeout(this.pendingTimer);
175 this.pendingTimer = null;
176 }
177
169178 computeErrorMessage(error: unknown): string | undefined {
170179 if (!error) return undefined;
171180 const mutation = this.mutation;
172 if (!mutation || !this.state.args) return errMessage(error);
173 return `Failed to ${mutation.describe(...this.state.args)}: ${errMessage(error)}`;
181 return formatFriendlyError(
182 this.state.args ? mutation?.describe(...this.state.args) ?? null : null,
183 error,
184 );
174185 }
175186
176187 run(...args: Args) {
......@@ -187,9 +198,9 @@ class Observer<Args extends unknown[], Result> {
187198 if (status === "idle") {
188199 this.setState({
189200 isMutating: false,
190 isPending: false,
191201 isOptimisticData: false,
192202 });
203 this.resetPending();
193204 return;
194205 }
195206 const hasError = error != null;
......@@ -204,16 +215,25 @@ class Observer<Args extends unknown[], Result> {
204215 ? "mutating"
205216 : "idle",
206217 error: error ?? undefined,
207 errorMessage: this.computeErrorMessage(error ?? undefined),
218 errorMessage: this.state.error === error && this.state.errorMessage
219 ? this.state.errorMessage
220 : this.computeErrorMessage(error ?? undefined),
208221 result: result ?? undefined,
209222 isMutating: status === "mutating",
210 isPending: status === "mutating" || status === "refetching",
211223 isSuccess: hasResult && !hasError,
212224 isError: hasError,
213225 isOptimisticData: status === "waiting" || status === "mutating"
214226 || status === "refetching",
215227 args: hasError || hasResult ? undefined : this.state.args,
216228 });
229 if (!this.state.isPending && this.state.isMutating) {
230 this.pendingTimer = setTimeout(() => {
231 this.pendingTimer = null;
232 this.setState({ isPending: true });
233 }, 200);
234 } else {
235 this.resetPending();
236 }
217237 },
218238 );
219239 }
......@@ -235,8 +255,7 @@ class Observer<Args extends unknown[], Result> {
235255 });
236256 promise.catch((err) => {
237257 if (!watchesError) {
238 const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`;
239 mutation.client.reportError(message, err);
258 mutation.client.reportError(formatFriendlyError(mutation.describe(...args), errMessage(err)), err);
240259 }
241260 });
242261 return promise;
......@@ -279,7 +298,9 @@ class Observer<Args extends unknown[], Result> {
279298 ? "mutating"
280299 : "idle",
281300 error: error ?? undefined,
282 errorMessage: this.computeErrorMessage(error ?? undefined),
301 errorMessage: this.state.error === error && this.state.errorMessage
302 ? this.state.errorMessage
303 : this.computeErrorMessage(error ?? undefined),
283304 result: result ?? undefined,
284305 isMutating: status === "mutating",
285306 isPending: status === "mutating" || status === "refetching",
test/mutations.test.ts created+6
......@@ -0,0 +1,6 @@
1import { test } from "vitest";
2import { createTestMutationClient } from "./share.ts";
3
4test("apply optimistic update, refetch when done", () => {
5 const { client } = createTestMutationClient();
6});
test/share.ts created+79
......@@ -0,0 +1,79 @@
1import { MutationClient } from "../src/client.ts";
2
3export interface TestMutationClient {
4 client: MutationClient<{}, {}>;
5 errorMessages: Array<{ message: string; error: unknown }>;
6 successMessages: string[];
7}
8
9export function createTestMutationClient(): TestMutationClient {
10 const errorMessages: Array<{ message: string; error: unknown }> = [];
11 const successMessages: string[] = [];
12
13 const client = new MutationClient({
14 context: {},
15 getOptimisticHelpers: () => ({}),
16 reportError: (message: string, error: unknown) => {
17 errorMessages.push({ message, error });
18 },
19 reportSuccess: (message: string) => {
20 successMessages.push(message);
21 },
22 });
23
24 return { client, errorMessages, successMessages };
25}
26
27export class IterableStream<T> implements AsyncIterableIterator<T> {
28 #buffer: T[] = [];
29 #listeners = new Set<{
30 resolve(value: IteratorResult<T>): void;
31 reject(error: unknown): void;
32 }>();
33 #ended = false;
34
35 [Symbol.asyncIterator](): AsyncIterableIterator<T> {
36 return this;
37 }
38
39 async next(): Promise<IteratorResult<T>> {
40 if (this.#buffer.length > 0) {
41 return { done: false, value: this.#buffer.shift()! };
42 }
43 if (this.#ended) {
44 return { done: true, value: undefined };
45 }
46 return new Promise((resolve, reject) => this.#listeners.add({ resolve, reject }));
47 }
48
49 push(value: T) {
50 if (this.#listeners.size > 0) {
51 const listeners = [...this.#listeners];
52 this.#listeners.clear();
53 for (const listener of listeners) {
54 listener.resolve({ done: false, value });
55 }
56 } else {
57 this.#buffer.push(value);
58 }
59 }
60
61 throw(error: unknown) {
62 this.#buffer = [];
63 for (const listener of this.#listeners) {
64 listener.reject(error);
65 }
66 this.#listeners.clear();
67 return Promise.resolve({ done: true, value: undefined } as IteratorResult<T>);
68 }
69
70 end() {
71 this.#ended = true;
72 if (this.#listeners.size > 0) {
73 for (const listener of this.#listeners) {
74 listener.resolve({ done: true, value: undefined });
75 }
76 this.#listeners.clear();
77 }
78 }
79}
test/useMutate.test.tsx created+343
......@@ -0,0 +1,343 @@
1import { assertEquals } from "@std/assert";
2import { act, render, screen } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";
4import { test, vi } from "vitest";
5import { useMutate } from "../src/react.ts";
6import { createTestMutationClient, IterableStream } from "./share.ts";
7
8test("useMutate - global error and success 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 });
23
24 let renders: Array<{ isMutating: boolean; isPending: boolean }> = [];
25 function TestComponent() {
26 const { run, isMutating, isPending } = useMutate(mutTest);
27 renders.push({ isMutating, isPending });
28
29 return (
30 <>
31 <button
32 data-testid="a"
33 onClick={() => {
34 run();
35 }}
36 >
37 button
38 </button>
39 </>
40 );
41 }
42
43 render(<TestComponent />);
44 // initial state
45 assertEquals(renders, [{ isMutating: false, isPending: false }]);
46 assertEquals(successMessages, []);
47 assertEquals(errorMessages, []);
48 renders = [];
49 vi.runAllTimers();
50
51 // mutation 1
52 await act(() => user.click(screen.getByTestId("a")));
53 assertEquals(renders, [{ isMutating: true, isPending: false }]);
54 renders = [];
55 await act(() => vi.advanceTimersByTime(150));
56 assertEquals(renders, []);
57 await act(() => vi.advanceTimersByTime(50));
58 assertEquals(renders, [{ isMutating: true, isPending: true }]);
59 renders = [];
60 assertEquals(successMessages, []);
61 assertEquals(errorMessages, []);
62 await act(async () => {
63 s.push("ok");
64 vi.advanceTimersByTime(100);
65 });
66 assertEquals(successMessages, ["Tested the action"]);
67 assertEquals(errorMessages, []);
68 assertEquals(renders, [{ isMutating: false, isPending: false }]);
69 renders = [];
70
71 // mutation 2
72 await act(() => user.click(screen.getByTestId("a")));
73 assertEquals(renders, [{ isMutating: true, isPending: false }]);
74 renders = [];
75 await act(() => vi.advanceTimersByTime(150));
76 assertEquals(renders, []);
77 await act(() => vi.advanceTimersByTime(50));
78 assertEquals(renders, [{ isMutating: true, isPending: true }]);
79 renders = [];
80 assertEquals(successMessages, ["Tested the action"]);
81 assertEquals(errorMessages, []);
82 const error1 = new Error("damn!");
83 await act(async () => {
84 s.throw(error1);
85 vi.advanceTimersByTime(100);
86 });
87 assertEquals(successMessages, ["Tested the action"]);
88 assertEquals(errorMessages, [
89 { error: error1, message: "Could not test the action: damn!" },
90 ]);
91});
92
93test("useMutate - local error and success handling", async () => {
94 vi.useFakeTimers({ shouldAdvanceTime: true });
95 const user = userEvent.setup({ delay: null });
96
97 const { client, successMessages, errorMessages } = createTestMutationClient();
98 const s = new IterableStream<string>();
99
100 const mutTest = client.define({
101 mutate: async () => {
102 return (await s.next()).value;
103 },
104 describe: "Test the action",
105 describeResult: "Tested the action",
106 optimistic: () => {},
107 });
108
109 let renders: Array<
110 {
111 isMutating: boolean;
112 isPending: boolean;
113 result: string | undefined;
114 error: unknown;
115 errorMessage: string | undefined;
116 isSuccess: boolean;
117 isError: boolean;
118 isOptimisticData: boolean;
119 }
120 > = [];
121 function TestComponent() {
122 const { run, clear, isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData } =
123 useMutate(
124 mutTest,
125 );
126 renders.push({ isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData });
127
128 return (
129 <>
130 <button
131 data-testid="a"
132 onClick={() => {
133 run();
134 }}
135 >
136 button
137 </button>
138 <button
139 data-testid="b"
140 onClick={() => {
141 clear();
142 }}
143 >
144 clear
145 </button>
146 </>
147 );
148 }
149
150 render(<TestComponent />);
151 // initial state
152 assertEquals(renders, [{
153 error: undefined,
154 errorMessage: undefined,
155 isError: false,
156 isMutating: false,
157 isOptimisticData: false,
158 isPending: false,
159 isSuccess: false,
160 result: undefined,
161 }]);
162 assertEquals(successMessages, []);
163 assertEquals(errorMessages, []);
164 renders = [];
165 vi.runAllTimers();
166
167 // mutation 1 - success
168 await act(() => user.click(screen.getByTestId("a")));
169 assertEquals(renders, [{
170 error: undefined,
171 errorMessage: undefined,
172 isError: false,
173 isMutating: true,
174 isOptimisticData: true,
175 isPending: false,
176 isSuccess: false,
177 result: undefined,
178 }]);
179 renders = [];
180 await act(() => vi.advanceTimersByTime(150));
181 assertEquals(renders, []);
182 await act(() => vi.advanceTimersByTime(50));
183 assertEquals(renders, [{
184 error: undefined,
185 errorMessage: undefined,
186 isError: false,
187 isMutating: true,
188 isOptimisticData: true,
189 isPending: true,
190 isSuccess: false,
191 result: undefined,
192 }]);
193 renders = [];
194 assertEquals(successMessages, []);
195 assertEquals(errorMessages, []);
196 await act(async () => {
197 s.push("ok");
198 vi.advanceTimersByTime(100);
199 });
200 assertEquals(successMessages, []);
201 assertEquals(errorMessages, []);
202 assertEquals(renders, [{
203 error: undefined,
204 errorMessage: undefined,
205 isError: false,
206 isMutating: false,
207 isOptimisticData: false,
208 isPending: false,
209 isSuccess: true,
210 result: "ok",
211 }]);
212 renders = [];
213
214 // mutation 2 - failure
215 await act(() => user.click(screen.getByTestId("a")));
216 assertEquals(renders, [{
217 error: undefined,
218 errorMessage: undefined,
219 isError: false,
220 isMutating: true,
221 isOptimisticData: true,
222 isPending: false,
223 isSuccess: false,
224 result: undefined,
225 }]);
226 renders = [];
227 await act(() => vi.advanceTimersByTime(150));
228 assertEquals(renders, []);
229 await act(() => vi.advanceTimersByTime(50));
230 assertEquals(renders, [{
231 error: undefined,
232 errorMessage: undefined,
233 isError: false,
234 isMutating: true,
235 isOptimisticData: true,
236 isPending: true,
237 isSuccess: false,
238 result: undefined,
239 }]);
240 renders = [];
241 const error1 = new Error("damn!");
242 await act(async () => {
243 s.throw(error1);
244 vi.advanceTimersByTime(100);
245 });
246 assertEquals(renders, [{
247 error: error1,
248 errorMessage: "Could not test the action: damn!",
249 isError: true,
250 isMutating: false,
251 isOptimisticData: false,
252 isPending: false,
253 isSuccess: false,
254 result: undefined,
255 }]);
256 assertEquals(successMessages, []);
257 assertEquals(errorMessages, []);
258});
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});