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 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.11",3 "version": "1.0.0-beta.12",
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/client.ts-1
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
2import { BlockingMutation, type MutationOptions } from "./mutation.ts";1import { BlockingMutation, type MutationOptions } from "./mutation.ts";
3import type { Mutation } from "./types.ts";2import type { Mutation } from "./types.ts";
43
src/mutation.ts+20-7
...@@ -27,7 +27,7 @@ export interface MutationOptions<...@@ -27,7 +27,7 @@ export interface MutationOptions<
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
28 /**28 /**
29 * Used in error messages and debug tools.29 * 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(...)}`
31 */31 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);32 describe: string | ((context: Config["context"] & { args: Args }) => string);
33 /**33 /**
...@@ -263,8 +263,7 @@ export class BlockingMutation<...@@ -263,8 +263,7 @@ export class BlockingMutation<
263263
264 // Call global handler unless suppressed264 // Call global handler unless suppressed
265 if (!suppressGlobalError) {265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;266 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);
267 this.#client.reportError(message, error);
268 }267 }
269 });268 });
270269
...@@ -405,7 +404,7 @@ export class BlockingMutation<...@@ -405,7 +404,7 @@ export class BlockingMutation<
405 // Report any errors from refetch or callbacks404 // Report any errors from refetch or callbacks
406 results.forEach((result) => {405 results.forEach((result) => {
407 if (result.status === "rejected") {406 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)}`;
409 this.#client.reportError(message, result.reason);408 this.#client.reportError(message, result.reason);
410 }409 }
411 });410 });
...@@ -442,7 +441,7 @@ export class BlockingMutation<...@@ -442,7 +441,7 @@ export class BlockingMutation<
442 // Report any errors from refetch or callbacks441 // Report any errors from refetch or callbacks
443 results.forEach((result) => {442 results.forEach((result) => {
444 if (result.status === "rejected") {443 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)}`;
446 this.#client.reportError(message, result.reason);445 this.#client.reportError(message, result.reason);
447 }446 }
448 });447 });
...@@ -638,8 +637,13 @@ export class BlockingMutation<...@@ -638,8 +637,13 @@ export class BlockingMutation<
638 // Check if there are any listeners at execution time637 // Check if there are any listeners at execution time
639 const hasListeners = channel.listeners.size > 0;638 const hasListeners = channel.listeners.size > 0;
640 if (!hasListeners) {639 if (!hasListeners) {
641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;640 this.#client.reportError(
642 this.#client.reportError(message, error);641 formatFriendlyError(
642 this.describe(...args),
643 error,
644 ),
645 error,
646 );
643 }647 }
644 },648 },
645 );649 );
...@@ -660,3 +664,12 @@ export class BlockingMutation<...@@ -660,3 +664,12 @@ export class BlockingMutation<
660 // Otherwise, it will execute when the current item finishes664 // Otherwise, it will execute when the current item finishes
661 }665 }
662}666}
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 @@...@@ -1,4 +1,5 @@
1import { message as errMessage } from "@clo/lib/error.ts";1import { message as errMessage } from "@clo/lib/error.ts";
2import type { Timer } from "@clo/lib/ts.ts";
2import {3import {
3 type FC,4 type FC,
4 type MouseEvent,5 type MouseEvent,
...@@ -9,6 +10,7 @@ import {...@@ -9,6 +10,7 @@ import {
9 useState,10 useState,
10} from "react";11} from "react";
11import { jsx } from "react/jsx-runtime";12import { jsx } from "react/jsx-runtime";
13import { formatFriendlyError } from "./mutation.ts";
12import type { Mutation, RunOptions } from "./types.ts";14import type { Mutation, RunOptions } from "./types.ts";
1315
14/**16/**
...@@ -69,7 +71,7 @@ export interface UseMutateError {...@@ -69,7 +71,7 @@ export interface UseMutateError {
69 status: "error";71 status: "error";
70 result: undefined;72 result: undefined;
71 error: unknown;73 error: unknown;
72 /** User-friendly in this format: `Failed to {action}: {details}` */74 /** User-friendly in this format: `Could not {action}: {details}` */
73 errorMessage: string;75 errorMessage: string;
74 /** `true` when a `mutate` function is currently running. */76 /** `true` when a `mutate` function is currently running. */
75 isMutating: false;77 isMutating: false;
...@@ -138,6 +140,7 @@ class Observer<Args extends unknown[], Result> {...@@ -138,6 +140,7 @@ class Observer<Args extends unknown[], Result> {
138 mutation: Mutation<Args, Result> | null = null;140 mutation: Mutation<Args, Result> | null = null;
139 unsubscribe: (() => void) | null = null;141 unsubscribe: (() => void) | null = null;
140 currentKey: string | null = null;142 currentKey: string | null = null;
143 pendingTimer: Timer | null = null;
141144
142 constructor(setRerender: (fn: number) => void) {145 constructor(setRerender: (fn: number) => void) {
143 this.setRerender = setRerender;146 this.setRerender = setRerender;
...@@ -166,11 +169,19 @@ class Observer<Args extends unknown[], Result> {...@@ -166,11 +169,19 @@ class Observer<Args extends unknown[], Result> {
166 this.state = initialState();169 this.state = initialState();
167 }170 }
168171
172 resetPending() {
173 this.setState({ isPending: false });
174 if (this.pendingTimer) clearTimeout(this.pendingTimer);
175 this.pendingTimer = null;
176 }
177
169 computeErrorMessage(error: unknown): string | undefined {178 computeErrorMessage(error: unknown): string | undefined {
170 if (!error) return undefined;179 if (!error) return undefined;
171 const mutation = this.mutation;180 const mutation = this.mutation;
172 if (!mutation || !this.state.args) return errMessage(error);181 return formatFriendlyError(
173 return `Failed to ${mutation.describe(...this.state.args)}: ${errMessage(error)}`;182 this.state.args ? mutation?.describe(...this.state.args) ?? null : null,
183 error,
184 );
174 }185 }
175186
176 run(...args: Args) {187 run(...args: Args) {
...@@ -187,9 +198,9 @@ class Observer<Args extends unknown[], Result> {...@@ -187,9 +198,9 @@ class Observer<Args extends unknown[], Result> {
187 if (status === "idle") {198 if (status === "idle") {
188 this.setState({199 this.setState({
189 isMutating: false,200 isMutating: false,
190 isPending: false,
191 isOptimisticData: false,201 isOptimisticData: false,
192 });202 });
203 this.resetPending();
193 return;204 return;
194 }205 }
195 const hasError = error != null;206 const hasError = error != null;
...@@ -204,16 +215,25 @@ class Observer<Args extends unknown[], Result> {...@@ -204,16 +215,25 @@ class Observer<Args extends unknown[], Result> {
204 ? "mutating"215 ? "mutating"
205 : "idle",216 : "idle",
206 error: error ?? undefined,217 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),
208 result: result ?? undefined,221 result: result ?? undefined,
209 isMutating: status === "mutating",222 isMutating: status === "mutating",
210 isPending: status === "mutating" || status === "refetching",
211 isSuccess: hasResult && !hasError,223 isSuccess: hasResult && !hasError,
212 isError: hasError,224 isError: hasError,
213 isOptimisticData: status === "waiting" || status === "mutating"225 isOptimisticData: status === "waiting" || status === "mutating"
214 || status === "refetching",226 || status === "refetching",
215 args: hasError || hasResult ? undefined : this.state.args,227 args: hasError || hasResult ? undefined : this.state.args,
216 });228 });
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 }
217 },237 },
218 );238 );
219 }239 }
...@@ -235,8 +255,7 @@ class Observer<Args extends unknown[], Result> {...@@ -235,8 +255,7 @@ class Observer<Args extends unknown[], Result> {
235 });255 });
236 promise.catch((err) => {256 promise.catch((err) => {
237 if (!watchesError) {257 if (!watchesError) {
238 const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`;258 mutation.client.reportError(formatFriendlyError(mutation.describe(...args), errMessage(err)), err);
239 mutation.client.reportError(message, err);
240 }259 }
241 });260 });
242 return promise;261 return promise;
...@@ -279,7 +298,9 @@ class Observer<Args extends unknown[], Result> {...@@ -279,7 +298,9 @@ class Observer<Args extends unknown[], Result> {
279 ? "mutating"298 ? "mutating"
280 : "idle",299 : "idle",
281 error: error ?? undefined,300 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),
283 result: result ?? undefined,304 result: result ?? undefined,
284 isMutating: status === "mutating",305 isMutating: status === "mutating",
285 isPending: status === "mutating" || status === "refetching",306 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});