authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-30 13:49:44-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-30 17:18:24-08:00
logea5d25fad4f41fa5631a62cb4de13ff35d6d5277
tree4bd8e717f815581a2bee4647df599194abb0b4bd
parent53a378b4b8da853ca8250834e7fc040a1cf6ab27
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: a ton of bugs because i didn't write tests


17 files changed, 1504 insertions(+), 643 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.14",3 "version": "1.0.0-rc.1",
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",
package-lock.json+29
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12 "@std/assert": "npm:@jsr/std__assert@^1.0.17"12 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
13 },13 },
14 "devDependencies": {14 "devDependencies": {
15 "@tanstack/react-query": "^5.90.20",
15 "@testing-library/react": "^16.3.2",16 "@testing-library/react": "^16.3.2",
16 "@testing-library/user-event": "^14.6.1",17 "@testing-library/user-event": "^14.6.1",
17 "@types/node": "^24.10.1",18 "@types/node": "^24.10.1",
...@@ -1431,6 +1432,34 @@...@@ -1431,6 +1432,34 @@
1431 "@jsr/std__internal": "^1.0.12"1432 "@jsr/std__internal": "^1.0.12"
1432 }1433 }
1433 },1434 },
1435 "node_modules/@tanstack/query-core": {
1436 "version": "5.90.20",
1437 "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
1438 "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
1439 "dev": true,
1440 "license": "MIT",
1441 "funding": {
1442 "type": "github",
1443 "url": "https://github.com/sponsors/tannerlinsley"
1444 }
1445 },
1446 "node_modules/@tanstack/react-query": {
1447 "version": "5.90.20",
1448 "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
1449 "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
1450 "dev": true,
1451 "license": "MIT",
1452 "dependencies": {
1453 "@tanstack/query-core": "5.90.20"
1454 },
1455 "funding": {
1456 "type": "github",
1457 "url": "https://github.com/sponsors/tannerlinsley"
1458 },
1459 "peerDependencies": {
1460 "react": "^18 || ^19"
1461 }
1462 },
1434 "node_modules/@testing-library/dom": {1463 "node_modules/@testing-library/dom": {
1435 "version": "10.4.1",1464 "version": "10.4.1",
1436 "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",1465 "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
package.json+1
...@@ -16,6 +16,7 @@...@@ -16,6 +16,7 @@
16 "@std/assert": "npm:@jsr/std__assert@^1.0.17"16 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
17 },17 },
18 "devDependencies": {18 "devDependencies": {
19 "@tanstack/react-query": "^5.90.20",
19 "@testing-library/react": "^16.3.2",20 "@testing-library/react": "^16.3.2",
20 "@testing-library/user-event": "^14.6.1",21 "@testing-library/user-event": "^14.6.1",
21 "@types/node": "^24.10.1",22 "@types/node": "^24.10.1",
src/mutation.ts+80-30
...@@ -63,6 +63,14 @@ export interface MutationOptions<...@@ -63,6 +63,14 @@ export interface MutationOptions<
63 * All pending promises resolve with the final result.63 * All pending promises resolve with the final result.
64 */64 */
65 debounceMs?: number;65 debounceMs?: number;
66 /**
67 * When true, the first call executes immediately (leading edge), then
68 * subsequent rapid calls are debounced (trailing edge). After the debounce
69 * period ends, the next call executes immediately again.
70 *
71 * Requires `debounceMs` to be set.
72 */
73 debounceImmediate?: boolean;
66 /**74 /**
67 * Called before and after optimistic updates to detect no-op mutations.75 * Called before and after optimistic updates to detect no-op mutations.
68 * If the snapshots are equal (using deepEquals), the mutation is cancelled.76 * If the snapshots are equal (using deepEquals), the mutation is cancelled.
...@@ -100,6 +108,15 @@ interface PendingDebouncedState<Args extends unknown[], Result> {...@@ -100,6 +108,15 @@ interface PendingDebouncedState<Args extends unknown[], Result> {
100 onSuccess: Array<(result: Result) => void>;108 onSuccess: Array<(result: Result) => void>;
101 /** Initial snapshot before any debounced calls (for no-op detection) */109 /** Initial snapshot before any debounced calls (for no-op detection) */
102 initialSnapshot?: unknown;110 initialSnapshot?: unknown;
111 /** Whether the last call wanted global handlers to be called */
112 shouldCallGlobalHandler: boolean;
113}
114
115/** Wrapper for errors that includes the description captured before rollback */
116interface MutationError {
117 __mutationError: true;
118 error: unknown;
119 description: string;
103}120}
104121
105interface Channel<Args extends unknown[], Result, OptimisticHelpers> {122interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
...@@ -113,6 +130,8 @@ interface Channel<Args extends unknown[], Result, OptimisticHelpers> {...@@ -113,6 +130,8 @@ interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
113 // Debounce state (only used if debounce option is set)130 // Debounce state (only used if debounce option is set)
114 debounceTimer: ReturnType<typeof setTimeout> | null;131 debounceTimer: ReturnType<typeof setTimeout> | null;
115 pendingDebounced: PendingDebouncedState<Args, Result> | null;132 pendingDebounced: PendingDebouncedState<Args, Result> | null;
133 // Track when last debounced mutation executed (for debounceImmediate)
134 lastDebouncedExecutionTime: number | null;
116}135}
117136
118interface Item<Args extends unknown[], Result> {137interface Item<Args extends unknown[], Result> {
...@@ -164,6 +183,7 @@ export class BlockingMutation<...@@ -164,6 +183,7 @@ export class BlockingMutation<
164 helpers: null,183 helpers: null,
165 debounceTimer: null,184 debounceTimer: null,
166 pendingDebounced: null,185 pendingDebounced: null,
186 lastDebouncedExecutionTime: null,
167 };187 };
168 this.#channels.set(key, channel);188 this.#channels.set(key, channel);
169 }189 }
...@@ -185,7 +205,7 @@ export class BlockingMutation<...@@ -185,7 +205,7 @@ export class BlockingMutation<
185 result: Result | null = null,205 result: Result | null = null,
186 error: unknown = null,206 error: unknown = null,
187 ) {207 ) {
188 const event: MutationEvent<Result> = { status, result, error };208 const event: MutationEvent<Result> = { status, result, error, debounced: this.#options.debounceMs !== undefined };
189 channel.listeners.forEach((cb) => cb(event));209 channel.listeners.forEach((cb) => cb(event));
190 }210 }
191211
...@@ -247,10 +267,11 @@ export class BlockingMutation<...@@ -247,10 +267,11 @@ export class BlockingMutation<
247 const args = array.slice() as Args;267 const args = array.slice() as Args;
248 const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args268 const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args
249 .pop() as RunOptions<Result>;269 .pop() as RunOptions<Result>;
250 const suppressGlobalSuccess = onSuccess !== undefined;270 const suppressAll = this.#options.debounceMs !== undefined && !onSuccess && !onError;
251 const suppressGlobalError = onError !== undefined;271 const suppressGlobalSuccess = onSuccess !== undefined || suppressAll;
272 const suppressGlobalError = onError !== undefined || suppressAll;
252273
253 const promise = this.#runWithOptions(args, { onRestore });274 const promise = this.#runWithOptions(args, onRestore, suppressAll);
254 promise.then((result) => {275 promise.then((result) => {
255 // Call user handlers276 // Call user handlers
256 onSuccess?.(result);277 onSuccess?.(result);
...@@ -264,21 +285,27 @@ export class BlockingMutation<...@@ -264,21 +285,27 @@ export class BlockingMutation<
264 this.#client.reportSuccess(message);285 this.#client.reportSuccess(message);
265 }286 }
266 }287 }
267 }).catch((error) => {288 }).catch((caught: unknown) => {
268 // Call user handlers289 // Extract error and description if this is a wrapped mutation error
290 const isMutationError = (caught as MutationError)?.__mutationError === true;
291 const error = isMutationError ? (caught as MutationError).error : caught;
292 const description = isMutationError ? (caught as MutationError).description : this.describe(...args);
293
294 // Call user handlers with the unwrapped error
269 onError?.(error);295 onError?.(error);
270 onSettled?.({ status: "error", error });296 onSettled?.({ status: "error", error });
271297
272 // Call global handler unless suppressed298 // Call global handler unless suppressed
273 if (!suppressGlobalError) {299 if (!suppressGlobalError) {
274 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);300 this.#client.reportError(formatFriendlyError(description, error), error);
275 }301 }
276 });302 });
277 }303 }
278304
279 #runWithOptions(305 #runWithOptions(
280 args: Args,306 args: Args,
281 { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">,307 userOnRestore: RunOptions<Result>["onRestore"],
308 suppressGlobalHandlers: boolean,
282 ): Promise<Result> {309 ): Promise<Result> {
283 if (!this.#client.enabled) {310 if (!this.#client.enabled) {
284 throw new Error(311 throw new Error(
...@@ -290,7 +317,20 @@ export class BlockingMutation<...@@ -290,7 +317,20 @@ export class BlockingMutation<
290317
291 // Check if debouncing is enabled318 // Check if debouncing is enabled
292 if (this.#options.debounceMs !== undefined) {319 if (this.#options.debounceMs !== undefined) {
293 return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true);320 // Check if we should execute immediately (leading edge)
321 const shouldExecuteImmediate = this.#options.debounceImmediate && (
322 channel.lastDebouncedExecutionTime === null
323 || Date.now() - channel.lastDebouncedExecutionTime >= this.#options.debounceMs
324 );
325
326 return this.#runDebouncedAndReturn(
327 args,
328 key,
329 channel,
330 userOnRestore,
331 !!shouldExecuteImmediate,
332 suppressGlobalHandlers,
333 );
294 }334 }
295335
296 // Take snapshot before optimistic update (if snapshot function defined)336 // Take snapshot before optimistic update (if snapshot function defined)
...@@ -450,6 +490,10 @@ export class BlockingMutation<...@@ -450,6 +490,10 @@ export class BlockingMutation<
450 }490 }
451 resolve(result);491 resolve(result);
452 }, (error) => {492 }, (error) => {
493 // Capture description BEFORE rollback so it sees optimistic state
494 const description = this.describe(...args);
495 const wrappedError: MutationError = { __mutationError: true, error, description };
496
453 // if an error happens, then every rollback is called in reverse order497 // if an error happens, then every rollback is called in reverse order
454 let next;498 let next;
455 while (next = channel.rollbacks.pop()) next();499 while (next = channel.rollbacks.pop()) next();
...@@ -457,7 +501,7 @@ export class BlockingMutation<...@@ -457,7 +501,7 @@ export class BlockingMutation<
457 // Cancel all remaining items in the channel501 // Cancel all remaining items in the channel
458 const remainingItems = channel.queue.splice(0);502 const remainingItems = channel.queue.splice(0);
459 remainingItems.forEach((queuedItem) => {503 remainingItems.forEach((queuedItem) => {
460 queuedItem.reject(error);504 queuedItem.reject(wrappedError);
461 });505 });
462506
463 // Notify listeners of the error507 // Notify listeners of the error
...@@ -480,7 +524,7 @@ export class BlockingMutation<...@@ -480,7 +524,7 @@ export class BlockingMutation<
480 this.#setIdle(key, channel);524 this.#setIdle(key, channel);
481 });525 });
482526
483 reject(error);527 reject(wrappedError);
484 });528 });
485 }529 }
486530
...@@ -488,8 +532,9 @@ export class BlockingMutation<...@@ -488,8 +532,9 @@ export class BlockingMutation<
488 args: Args,532 args: Args,
489 key: string,533 key: string,
490 channel: Channel<Args, Result, Config["optimisticHelpers"]>,534 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
491 userOnRestore?: () => void,535 userOnRestore: (() => void) | undefined,
492 fromRunWithOptions = false,536 shouldExecuteImmediate: boolean,
537 shouldCallGlobalHandler: boolean,
493 ): Promise<Result> {538 ): Promise<Result> {
494 // Capture initial snapshot before first debounced call539 // Capture initial snapshot before first debounced call
495 const isFirstDebouncedCall = channel.pendingDebounced === null;540 const isFirstDebouncedCall = channel.pendingDebounced === null;
...@@ -622,6 +667,7 @@ export class BlockingMutation<...@@ -622,6 +667,7 @@ export class BlockingMutation<
622 pending: [{ resolve, reject }],667 pending: [{ resolve, reject }],
623 onSuccess,668 onSuccess,
624 initialSnapshot: isFirstDebouncedCall ? initialSnapshot : undefined,669 initialSnapshot: isFirstDebouncedCall ? initialSnapshot : undefined,
670 shouldCallGlobalHandler,
625 };671 };
626672
627 // Set status to waiting673 // Set status to waiting
...@@ -633,6 +679,7 @@ export class BlockingMutation<...@@ -633,6 +679,7 @@ export class BlockingMutation<
633 channel.pendingDebounced.rollbackCount = rollbacks;679 channel.pendingDebounced.rollbackCount = rollbacks;
634 channel.pendingDebounced.pending.push({ resolve, reject });680 channel.pendingDebounced.pending.push({ resolve, reject });
635 channel.pendingDebounced.onSuccess = onSuccess;681 channel.pendingDebounced.onSuccess = onSuccess;
682 channel.pendingDebounced.shouldCallGlobalHandler = shouldCallGlobalHandler;
636 // Keep the initial snapshot from the first call683 // Keep the initial snapshot from the first call
637 // Status stays "waiting"684 // Status stays "waiting"
638 }685 }
...@@ -642,10 +689,11 @@ export class BlockingMutation<...@@ -642,10 +689,11 @@ export class BlockingMutation<
642 clearTimeout(channel.debounceTimer);689 clearTimeout(channel.debounceTimer);
643 }690 }
644691
645 // Start new timer692 // Start new timer (0ms for immediate execution, debounceMs otherwise)
693 const delay = shouldExecuteImmediate ? 0 : this.#options.debounceMs!;
646 channel.debounceTimer = setTimeout(() => {694 channel.debounceTimer = setTimeout(() => {
647 this.#enqueueDebouncedCall(key, channel);695 this.#enqueueDebouncedCall(key, channel);
648 }, this.#options.debounceMs);696 }, delay);
649697
650 return promise;698 return promise;
651 }699 }
...@@ -681,11 +729,13 @@ export class BlockingMutation<...@@ -681,11 +729,13 @@ export class BlockingMutation<
681 return;729 return;
682 }730 }
683731
684 const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced;732 const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced;
685 channel.pendingDebounced = null;733 channel.pendingDebounced = null;
686734
687 // Check if there are any listeners at time of enqueue735 // Track execution time for debounceImmediate
688 const hasListeners = channel.listeners.size > 0;736 if (this.#options.debounceImmediate) {
737 channel.lastDebouncedExecutionTime = Date.now();
738 }
689739
690 // Create wrapper resolve/reject that resolves ALL pending promises740 // Create wrapper resolve/reject that resolves ALL pending promises
691 const {741 const {
...@@ -700,27 +750,27 @@ export class BlockingMutation<...@@ -700,27 +750,27 @@ export class BlockingMutation<
700 // Resolve all pending promises750 // Resolve all pending promises
701 pending.forEach((p) => p.resolve(result));751 pending.forEach((p) => p.resolve(result));
702752
703 // Check if there are any listeners at execution time753 // Call global handler if needed (based on whether component is watching success)
704 const hasListeners = channel.listeners.size > 0;754 if (shouldCallGlobalHandler) {
705 if (!hasListeners) {
706 const message = this.describeResult(args, result);755 const message = this.describeResult(args, result);
707 if (message && this.#client.reportSuccess) {756 if (message && this.#client.reportSuccess) {
708 this.#client.reportSuccess(message);757 this.#client.reportSuccess(message);
709 }758 }
710 }759 }
711 },760 },
712 (error) => {761 (caught) => {
713 // Reject all pending promises762 // Extract error and description if this is a wrapped mutation error
763 const isMutationError = (caught as MutationError)?.__mutationError === true;
764 const error = isMutationError ? (caught as MutationError).error : caught;
765 const description = isMutationError ? (caught as MutationError).description : this.describe(...args);
766
767 // Reject all pending promises with unwrapped error
714 pending.forEach((p) => p.reject(error));768 pending.forEach((p) => p.reject(error));
715769
716 // Check if there are any listeners at execution time770 // Call global handler if needed (based on whether component is watching errors)
717 const hasListeners = channel.listeners.size > 0;771 if (shouldCallGlobalHandler) {
718 if (!hasListeners) {
719 this.#client.reportError(772 this.#client.reportError(
720 formatFriendlyError(773 formatFriendlyError(description, error),
721 this.describe(...args),
722 error,
723 ),
724 error,774 error,
725 );775 );
726 }776 }
src/react.ts+13-5
...@@ -150,6 +150,7 @@ class Observer<Args extends unknown[], Result> {...@@ -150,6 +150,7 @@ class Observer<Args extends unknown[], Result> {
150 unsubscribe: (() => void) | null = null;150 unsubscribe: (() => void) | null = null;
151 currentKey: string | null = null;151 currentKey: string | null = null;
152 pendingTimer: Timer | null = null;152 pendingTimer: Timer | null = null;
153 debounced: boolean;
153154
154 constructor(setRerender: (fn: number) => void) {155 constructor(setRerender: (fn: number) => void) {
155 this.setRerender = setRerender;156 this.setRerender = setRerender;
...@@ -176,6 +177,7 @@ class Observer<Args extends unknown[], Result> {...@@ -176,6 +177,7 @@ class Observer<Args extends unknown[], Result> {
176 this.unsubscribe = null;177 this.unsubscribe = null;
177 this.currentKey = null;178 this.currentKey = null;
178 this.state = initialState();179 this.state = initialState();
180 this.debounced = false;
179 }181 }
180182
181 resetPending() {183 resetPending() {
...@@ -203,7 +205,9 @@ class Observer<Args extends unknown[], Result> {...@@ -203,7 +205,9 @@ class Observer<Args extends unknown[], Result> {
203 this.unsubscribe?.();205 this.unsubscribe?.();
204 this.unsubscribe = mutation.subscribe(206 this.unsubscribe = mutation.subscribe(
205 mutation.key(args),207 mutation.key(args),
206 ({ status, error, result }) => {208 ({ status, error, result, debounced }) => {
209 this.debounced = debounced;
210
207 if (status === "idle") {211 if (status === "idle") {
208 this.setState({212 this.setState({
209 isMutating: false,213 isMutating: false,
...@@ -232,10 +236,11 @@ class Observer<Args extends unknown[], Result> {...@@ -232,10 +236,11 @@ class Observer<Args extends unknown[], Result> {
232 isSuccess: hasResult && !hasError,236 isSuccess: hasResult && !hasError,
233 isError: hasError,237 isError: hasError,
234 isOptimisticData: status === "waiting" || status === "mutating"238 isOptimisticData: status === "waiting" || status === "mutating"
235 || status === "refetching",239 || status === "refetching" || (hasError && status !== "idle"),
236 args: hasError || hasResult ? undefined : this.state.args,240 args: hasError || hasResult ? undefined : this.state.args,
237 });241 });
238 if (!this.state.isPending && this.state.isMutating) {242
243 if (!this.state.isPending && this.state.isMutating && !debounced) {
239 this.pendingTimer = setTimeout(() => {244 this.pendingTimer = setTimeout(() => {
240 this.pendingTimer = null;245 this.pendingTimer = null;
241 this.setState({ isPending: true });246 this.setState({ isPending: true });
...@@ -258,6 +263,9 @@ class Observer<Args extends unknown[], Result> {...@@ -258,6 +263,9 @@ class Observer<Args extends unknown[], Result> {
258 {263 {
259 onSuccess: watchesSuccess ? () => {} : undefined,264 onSuccess: watchesSuccess ? () => {} : undefined,
260 onError: watchesError ? () => {} : undefined,265 onError: watchesError ? () => {} : undefined,
266 // For debounced mutations, suppress global handlers in runWithOptions
267 // The debounce logic (#enqueueDebouncedCall) will call them once if needed
268 // But only if the component isn't watching success/error
261 } satisfies RunOptions<Result>,269 } satisfies RunOptions<Result>,
262 );270 );
263 return promise;271 return promise;
...@@ -309,7 +317,7 @@ class Observer<Args extends unknown[], Result> {...@@ -309,7 +317,7 @@ class Observer<Args extends unknown[], Result> {
309 isSuccess: hasResult && !hasError,317 isSuccess: hasResult && !hasError,
310 isError: hasError,318 isError: hasError,
311 isOptimisticData: status === "waiting" || status === "mutating"319 isOptimisticData: status === "waiting" || status === "mutating"
312 || status === "refetching",320 || status === "refetching" || (hasError && status !== "idle"),
313 args: hasError || hasResult ? undefined : this.state.args,321 args: hasError || hasResult ? undefined : this.state.args,
314 });322 });
315 },323 },
...@@ -368,7 +376,7 @@ class Observer<Args extends unknown[], Result> {...@@ -368,7 +376,7 @@ class Observer<Args extends unknown[], Result> {
368 // TODO: when auth drops this will be dependant on the auth status and isMutating376 // TODO: when auth drops this will be dependant on the auth status and isMutating
369 get isDisabled() {377 get isDisabled() {
370 self.watched.add("isMutating");378 self.watched.add("isMutating");
371 return !self.mutation || self.state.isMutating;379 return !self.mutation || (self.state.isMutating && !self.debounced);
372 },380 },
373 get isPending() {381 get isPending() {
374 self.watched.add("isPending");382 self.watched.add("isPending");
src/types.ts+3
...@@ -33,10 +33,13 @@ export interface RunOptions<Result> {...@@ -33,10 +33,13 @@ export interface RunOptions<Result> {
33 ) => void;33 ) => void;
34 /** Called when optimistic state is being restored/rolled back */34 /** Called when optimistic state is being restored/rolled back */
35 onRestore?: () => void;35 onRestore?: () => void;
36 /** @internal Suppresses global handlers for debounced mutations */
37 __suppressGlobalForDebounce?: boolean;
36}38}
3739
38export interface MutationEvent<Result> {40export interface MutationEvent<Result> {
39 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";41 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";
40 result: Result | null;42 result: Result | null;
41 error: unknown;43 error: unknown;
44 debounced: boolean;
42}45}
test/cases/runWithOptions.test.tsx deleted-68
...@@ -1,68 +0,0 @@
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 optimistic: ({ onSuccess }) => {
20 onSuccess(() => {});
21 },
22 refetchOnSuccess: false,
23 describe: "Test the action",
24 describeResult: "Tested the action",
25 });
26
27 let renders: Array<{ status: string; result: string | undefined }> = [];
28 function TestComponent() {
29 const { runWithOptions, status, result } = useMutate(mutTest);
30 renders.push({ status, result });
31 return (
32 <button
33 data-testid="a"
34 onClick={() => {
35 runWithOptions({ onSuccessDataOnly: () => {} });
36 }}
37 >
38 button
39 </button>
40 );
41 }
42
43 render(<TestComponent />);
44 // initial state
45 assertEquals(renders, [{ status: "idle", result: undefined }]);
46 assertEquals(successMessages, []);
47 assertEquals(errorMessages, []);
48 renders = [];
49 vi.runAllTimers();
50
51 // mutation 1 - success
52 await act(() => user.click(screen.getByTestId("a")));
53 assertEquals(renders, [{ status: "mutating", result: undefined }]);
54 renders = [];
55 await act(async () => {
56 s.push("ok");
57 vi.advanceTimersByTime(100);
58 });
59 assertEquals(successMessages, ["Tested the action"]);
60 assertEquals(errorMessages, []);
61 assertEquals(renders, [{ status: "success", result: "ok" }]);
62 renders = [];
63 await act(async () => {
64 vi.advanceTimersByTime(10000);
65 });
66 assertEquals(renders, []);
67 renders = [];
68});
test/cases/setError.test.tsx deleted-260
...@@ -1,260 +0,0 @@
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/cases/snapshot.test.tsx deleted-231
...@@ -1,231 +0,0 @@
1import { useMutate } from "@clo/react-mutation";
2import { assert, 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("snapshot should skip no-op mutation", 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 let state = { value: "initial" };
16 let failed = false;
17 const mutUpdate = client.define({
18 mutate: async (newValue: string) => {
19 failed = true;
20 },
21 optimistic: ({ args: [newValue] }) => {
22 state.value = newValue;
23 },
24 snapshot: () => state.value,
25 describe: "Update value",
26 describeResult: "Updated value",
27 });
28
29 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
30 function TestComponent() {
31 const { run, status, isOptimisticData } = useMutate(mutUpdate);
32 renders.push({ status, isOptimisticData });
33 return (
34 <button data-testid="btn" onClick={() => run("initial")}>
35 update
36 </button>
37 );
38 }
39
40 render(<TestComponent />);
41 assertEquals(renders, [{ status: "idle", isOptimisticData: false }]);
42 renders = [];
43
44 // Click to run mutation with same value (no-op)
45 await act(() => user.click(screen.getByTestId("btn")));
46 vi.runAllTimers();
47
48 // Should skip the mutation and return to idle without calling API
49 assert(!failed);
50 assertEquals(state.value, "initial");
51 assertEquals(renders, []); // nothing changed
52 assertEquals(successMessages, []); // nothing happened
53 assertEquals(errorMessages, []);
54});
55
56test("snapshot should allow mutation when value changes", async () => {
57 vi.useFakeTimers({ shouldAdvanceTime: true });
58 const user = userEvent.setup({ delay: null });
59
60 const { client, successMessages, errorMessages } = createTestMutationClient();
61 const s = new IterableStream<string>();
62
63 let state = { value: "initial" };
64
65 const mutUpdate = client.define({
66 mutate: async (newValue: string) => {
67 return (await s.next()).value;
68 },
69 optimistic: ({ args: [newValue] }) => {
70 state.value = newValue;
71 },
72 snapshot: () => state.value,
73 describe: "Update value",
74 describeResult: "Updated value",
75 });
76
77 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
78 function TestComponent() {
79 const { run, status, isOptimisticData } = useMutate(mutUpdate);
80 renders.push({ status, isOptimisticData });
81 return (
82 <button data-testid="btn" onClick={() => run("changed")}>
83 update
84 </button>
85 );
86 }
87
88 render(<TestComponent />);
89 renders = [];
90
91 // Click to run mutation with different value
92 await act(() => user.click(screen.getByTestId("btn")));
93 assertEquals(state.value, "changed"); // Optimistic applied
94 assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]);
95 renders = [];
96
97 // Complete the mutation
98 await act(async () => {
99 s.push("ok");
100 vi.advanceTimersByTime(100);
101 });
102
103 assertEquals(renders, [{ status: "success", isOptimisticData: false }]);
104 assertEquals(successMessages, ["Updated value"]);
105 assertEquals(errorMessages, []);
106});
107
108test("debounced snapshot should skip when final state equals initial", async () => {
109 vi.useFakeTimers({ shouldAdvanceTime: true });
110 const user = userEvent.setup({ delay: null });
111
112 const { client, successMessages, errorMessages } = createTestMutationClient();
113 const s = new IterableStream<string>();
114
115 let state = { value: "initial" };
116
117 const mutations: string[] = [];
118 const mutUpdate = client.define({
119 mutate: async (newValue: string) => {
120 mutations.push(newValue);
121 },
122 optimistic: ({ args: [newValue] }) => {
123 state.value = newValue;
124 },
125 snapshot: () => state.value,
126 debounceMs: 500,
127 refetchOnSuccess: false,
128 describe: "Update value",
129 describeResult: "Updated value",
130 });
131
132 let renders: Array<{ status: string }> = [];
133 function TestComponent() {
134 const { run, status } = useMutate(mutUpdate);
135 renders.push({ status });
136 return (
137 <>
138 <button data-testid="a" onClick={() => run("changed")}>A</button>
139 <button data-testid="b" onClick={() => run("initial")}>B</button>
140 </>
141 );
142 }
143
144 render(<TestComponent />);
145 assertEquals(renders, [{ status: "idle" }]);
146 renders = [];
147
148 // First call - change value
149 await act(() => user.click(screen.getByTestId("a")));
150 assertEquals(state.value, "changed");
151 assertEquals(renders, []); // Debounced mutations don't show mutating, no update
152 assertEquals(mutations, []);
153 renders = [];
154
155 // Second call - revert to initial (no-op overall)
156 await act(() => user.click(screen.getByTestId("b")));
157 assertEquals(state.value, "initial"); // Should be rolled back to initial
158 assertEquals(renders, []); // Still idle
159 assertEquals(successMessages, []);
160 assertEquals(errorMessages, []);
161 assertEquals(mutations, []);
162
163 await act(() => vi.runAllTimers());
164 assertEquals(successMessages, []);
165 assertEquals(errorMessages, []);
166 assertEquals(mutations, []);
167});
168
169test("debounced snapshot should mutate when final differs from initial", async () => {
170 vi.useFakeTimers({ shouldAdvanceTime: true });
171 const user = userEvent.setup({ delay: null });
172
173 const { client, successMessages, errorMessages } = createTestMutationClient();
174 const s = new IterableStream<string>();
175
176 let state = { value: "initial" };
177
178 const mutUpdate = client.define({
179 mutate: async (newValue: string) => {
180 return (await s.next()).value;
181 },
182 optimistic: ({ args: [newValue] }) => {
183 state.value = newValue;
184 },
185 snapshot: () => state.value,
186 debounceMs: 500,
187 refetchOnSuccess: false,
188 describe: "Update value",
189 describeResult: "Updated value",
190 });
191
192 let renders: Array<{ status: string; result: string | undefined }> = [];
193 function TestComponent() {
194 const { run, status, result } = useMutate(mutUpdate);
195 renders.push({ status, result });
196 return (
197 <>
198 <button data-testid="a" onClick={() => run("temp")}>A</button>
199 <button data-testid="b" onClick={() => run("final")}>B</button>
200 </>
201 );
202 }
203
204 render(<TestComponent />);
205 renders = [];
206
207 // First call
208 await act(() => user.click(screen.getByTestId("a")));
209 assertEquals(state.value, "temp");
210 renders = [];
211
212 // Second call - different from initial
213 await act(() => user.click(screen.getByTestId("b")));
214 assertEquals(state.value, "final");
215 renders = [];
216
217 // Wait for debounce
218 await act(async () => {
219 vi.advanceTimersByTime(500);
220 });
221 assertEquals(renders, [{ status: "mutating", result: undefined }]);
222 renders = [];
223
224 // Complete mutation
225 await act(async () => {
226 s.push("ok");
227 vi.advanceTimersByTime(100);
228 });
229 assertEquals(renders, [{ status: "success", result: "ok" }]);
230 assertEquals(successMessages, []);
231});
test/debounce.test.tsx created+274
...@@ -0,0 +1,274 @@
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("debounceMs: waits before first call", 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 debounceMs: 500,
23 });
24
25 let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = [];
26 function TestComponent() {
27 const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest);
28 renders.push({ isMutating, isPending, isOptimisticData, isDisabled });
29
30 return (
31 <>
32 <button
33 data-testid="a"
34 onClick={() => {
35 run();
36 }}
37 >
38 button
39 </button>
40 </>
41 );
42 }
43
44 render(<TestComponent />);
45 // initial state
46 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]);
47 assertEquals(successMessages, []);
48 assertEquals(errorMessages, []);
49 renders = [];
50 vi.runAllTimers();
51
52 // mutation 1
53 await act(() => user.click(screen.getByTestId("a")));
54 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]);
55 renders = [];
56 await act(() => vi.advanceTimersByTime(200));
57 assertEquals(renders, []);
58 await act(() => vi.advanceTimersByTime(300));
59 assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]);
60 renders = [];
61 await act(() => vi.advanceTimersByTime(300));
62 assertEquals(renders, []);
63 assertEquals(successMessages, []);
64 assertEquals(errorMessages, []);
65 await act(async () => {
66 s.push("ok");
67 vi.advanceTimersByTime(100);
68 });
69 assertEquals(successMessages, ["Tested the action"]);
70 assertEquals(errorMessages, []);
71 assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]);
72 renders = [];
73 successMessages.splice(0, successMessages.length);
74
75 vi.advanceTimersByTime(1000);
76
77 // mutation 2
78 await act(() => user.click(screen.getByTestId("a")));
79 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]);
80 renders = [];
81 await act(() => vi.advanceTimersByTime(200));
82 assertEquals(renders, []);
83 await act(() => vi.advanceTimersByTime(300));
84 assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]);
85 renders = [];
86 await act(() => vi.advanceTimersByTime(300));
87 assertEquals(renders, []);
88 assertEquals(successMessages, []);
89 assertEquals(errorMessages, []);
90 const error1 = new Error("damn!");
91 await act(async () => {
92 s.throw(error1);
93 vi.advanceTimersByTime(100);
94 });
95 assertEquals(successMessages, []);
96 assertEquals(errorMessages, [
97 { error: error1, message: "Could not test the action: damn!" },
98 ]);
99});
100
101test("debounceMs: batch multiple calls together", async () => {
102 vi.useFakeTimers({ shouldAdvanceTime: true });
103 const user = userEvent.setup({ delay: null });
104
105 const { client, successMessages, errorMessages } = createTestMutationClient();
106 const s = new IterableStream<string>();
107
108 const mutations: number[] = [];
109 const mutTest = client.define({
110 mutate: async (k: number) => {
111 mutations.push(k);
112 return (await s.next()).value;
113 },
114 describe: "Test the action",
115 describeResult: "Tested the action",
116 optimistic: () => {},
117 debounceMs: 500,
118 });
119
120 let i = 0;
121
122 let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = [];
123 function TestComponent() {
124 const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest);
125 renders.push({ isMutating, isPending, isOptimisticData, isDisabled });
126
127 return (
128 <>
129 <button
130 data-testid="a"
131 onClick={() => {
132 run(i++);
133 }}
134 >
135 button
136 </button>
137 </>
138 );
139 }
140
141 render(<TestComponent />);
142 // initial state
143 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]);
144 assertEquals(successMessages, []);
145 assertEquals(errorMessages, []);
146 renders = [];
147 vi.runAllTimers();
148
149 // mutation 1
150 await act(() => user.click(screen.getByTestId("a")));
151 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]);
152 renders = [];
153 await act(() => user.click(screen.getByTestId("a")));
154 await act(() => user.click(screen.getByTestId("a")));
155 await act(() => user.click(screen.getByTestId("a")));
156 await act(() => user.click(screen.getByTestId("a")));
157 await act(() => vi.advanceTimersByTime(200));
158 assertEquals(renders, []);
159 await act(() => vi.advanceTimersByTime(300));
160 assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]);
161 renders = [];
162 await act(() => vi.advanceTimersByTime(300));
163 assertEquals(renders, []);
164 assertEquals(successMessages, []);
165 assertEquals(errorMessages, []);
166 await act(async () => {
167 assertEquals(mutations, [4]);
168 s.push("ok");
169 vi.advanceTimersByTime(100);
170 });
171 assertEquals(successMessages, ["Tested the action"]);
172 assertEquals(errorMessages, []);
173 assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]);
174 renders = [];
175 successMessages.splice(0, successMessages.length);
176
177 vi.advanceTimersByTime(1000);
178 assertEquals(renders, []);
179 renders = [];
180});
181
182test.todo("debounceImmediate runs the first one right away", async () => {
183 vi.useFakeTimers({ shouldAdvanceTime: true });
184 const user = userEvent.setup({ delay: null });
185
186 const { client, successMessages, errorMessages } = createTestMutationClient();
187 const s = new IterableStream<string>();
188
189 const mutations: number[] = [];
190 const mutTest = client.define({
191 mutate: async (k: number) => {
192 mutations.push(k);
193 return (await s.next()).value;
194 },
195 describe: "Test the action",
196 describeResult: "Tested the action",
197 optimistic: () => {},
198 debounceMs: 500,
199 debounceImmediate: true,
200 });
201
202 let i = 0;
203
204 let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = [];
205 function TestComponent() {
206 const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest);
207 renders.push({ isMutating, isPending, isOptimisticData, isDisabled });
208
209 return (
210 <>
211 <button
212 data-testid="a"
213 onClick={() => {
214 run(i++);
215 }}
216 >
217 button
218 </button>
219 </>
220 );
221 }
222
223 render(<TestComponent />);
224 // initial state
225 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]);
226 assertEquals(successMessages, []);
227 assertEquals(errorMessages, []);
228 renders = [];
229 vi.runAllTimers();
230
231 // mutation 1
232 await act(() => user.click(screen.getByTestId("a")));
233 assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true, isDisabled: false }]);
234 renders = [];
235 await act(async () => {
236 assertEquals(mutations, [0]);
237 s.push("ok");
238 vi.advanceTimersByTime(100);
239 });
240 assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]);
241 renders = [];
242
243 // mutation 2
244 await act(() => user.click(screen.getByTestId("a")));
245 assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]);
246 renders = [];
247
248 await act(() => user.click(screen.getByTestId("a")));
249 await act(() => user.click(screen.getByTestId("a")));
250 await act(() => user.click(screen.getByTestId("a")));
251 await act(() => vi.advanceTimersByTime(200));
252 assertEquals(renders, []);
253 await act(() => vi.advanceTimersByTime(300));
254 assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]);
255 renders = [];
256 await act(() => vi.advanceTimersByTime(300));
257 assertEquals(renders, []);
258 assertEquals(successMessages, []);
259 assertEquals(errorMessages, []);
260 await act(async () => {
261 assertEquals(mutations, [1]);
262 s.push("ok");
263 vi.advanceTimersByTime(100);
264 });
265 assertEquals(successMessages, ["Tested the action"]);
266 assertEquals(errorMessages, []);
267 assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]);
268 renders = [];
269 successMessages.splice(0, successMessages.length);
270
271 vi.advanceTimersByTime(1000);
272 assertEquals(renders, []);
273 renders = [];
274});
test/mutations.test.ts deleted-48
...@@ -1,48 +0,0 @@
1import { delay } from "@clo/lib/async.ts";
2import { MutationClient } from "@clo/react-mutation";
3import { assertEquals } from "@std/assert";
4import { test, vi } from "vitest";
5import { createTestMutationClient } from "./share.ts";
6
7test("run executes callbacks in correct order", async () => {
8 vi.useFakeTimers();
9 const { client, errorMessages, successMessages } = createTestMutationClient();
10 const calls: string[] = [];
11 const mutTest = client.define({
12 mutate: async () => {
13 calls.push("mutate");
14 await delay(100);
15 return "success";
16 },
17 describe: () => {
18 calls.push("describe");
19 return "Test the action";
20 },
21 describeResult: () => {
22 calls.push("describeResult");
23 return "Tested the action";
24 },
25 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
26 onRefetch(async () => void calls.push("refetch"));
27 onSuccess(async () => void calls.push("success"));
28 onRestore(async () => void calls.push("restore"));
29 calls.push("optimistic");
30 },
31 });
32 mutTest.run();
33 await vi.advanceTimersByTimeAsync(50);
34 assertEquals(calls, [
35 "optimistic",
36 "mutate",
37 ]);
38 await vi.advanceTimersByTimeAsync(100);
39 assertEquals(calls, [
40 "optimistic",
41 "mutate",
42 "success",
43 "refetch",
44 "describeResult",
45 ]);
46 assertEquals(errorMessages, []);
47 assertEquals(successMessages, ["Tested the action"]);
48});
test/optimistic.test.tsx created+117
...@@ -0,0 +1,117 @@
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.each([
9 [true],
10 [false],
11])("useMutate - isOptimisticData stays true while refetching (refetchOnSuccess = %s)", async (refetchOnSuccess) => {
12 vi.useFakeTimers({ shouldAdvanceTime: true });
13 const user = userEvent.setup({ delay: null });
14
15 const { client, successMessages, errorMessages } = createTestMutationClient();
16
17 const streamResults = new IterableStream<string>();
18 const streamRefreshes = new IterableStream<string>();
19
20 const mutTest = client.define({
21 mutate: async () => {
22 return (await streamResults.next()).value;
23 },
24 describe: "Test the action",
25 describeResult: "Tested the action",
26 optimistic: ({ onRefetch }) => {
27 onRefetch(async () => {
28 await streamRefreshes.next();
29 });
30 },
31 refetchOnSuccess,
32 });
33
34 let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean }> = [];
35 function TestComponent() {
36 const { run, isMutating, isPending, isOptimisticData } = useMutate(mutTest);
37 renders.push({ isMutating, isPending, isOptimisticData });
38
39 return (
40 <>
41 <button
42 data-testid="a"
43 onClick={() => {
44 run();
45 }}
46 >
47 button
48 </button>
49 </>
50 );
51 }
52
53 render(<TestComponent />);
54 // initial state
55 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]);
56 assertEquals(successMessages, []);
57 assertEquals(errorMessages, []);
58 renders = [];
59 vi.runAllTimers();
60
61 // mutation 1
62 await act(() => user.click(screen.getByTestId("a")));
63 assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true }]);
64 renders = [];
65 await act(() => vi.advanceTimersByTime(150));
66 assertEquals(renders, []);
67 await act(() => vi.advanceTimersByTime(50));
68 assertEquals(renders, [{ isMutating: true, isPending: true, isOptimisticData: true }]);
69 renders = [];
70 assertEquals(successMessages, []);
71 assertEquals(errorMessages, []);
72 await act(async () => {
73 streamResults.push("ok");
74 vi.advanceTimersByTime(150);
75 });
76 assertEquals(successMessages, ["Tested the action"]);
77 assertEquals(errorMessages, []);
78 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: refetchOnSuccess }]);
79 renders = [];
80 if (refetchOnSuccess) {
81 await act(async () => {
82 streamRefreshes.push("refetch");
83 vi.advanceTimersByTime(100);
84 });
85 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]);
86 renders = [];
87 }
88 successMessages.splice(0, successMessages.length);
89
90 // mutation 2 - error
91 await act(() => user.click(screen.getByTestId("a")));
92 assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true }]);
93 renders = [];
94 await act(() => vi.advanceTimersByTime(150));
95 assertEquals(renders, []);
96 await act(() => vi.advanceTimersByTime(50));
97 assertEquals(renders, [{ isMutating: true, isPending: true, isOptimisticData: true }]);
98 renders = [];
99 assertEquals(successMessages, []);
100 assertEquals(errorMessages, []);
101 const error1 = new Error("damn!");
102 await act(async () => {
103 streamResults.throw(error1);
104 vi.advanceTimersByTime(100);
105 });
106 assertEquals(successMessages, []);
107 assertEquals(errorMessages, [
108 { error: error1, message: "Could not test the action: damn!" },
109 ]);
110 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true }]);
111 renders = [];
112 await act(async () => {
113 streamRefreshes.push("refetch");
114 vi.advanceTimersByTime(100);
115 });
116 assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]);
117});
test/ordering.test.ts created+426
...@@ -0,0 +1,426 @@
1import { delay } from "@clo/lib/async.ts";
2import { assertEquals } from "@std/assert";
3import { test, vi } from "vitest";
4import { createTestMutationClient } from "./share.ts";
5
6test.each([
7 false,
8 true,
9])("run executes callbacks in correct order (refetchOnSuccess=%s)", async (refetchOnSuccess) => {
10 vi.useFakeTimers();
11 const { client, errorMessages, successMessages } = createTestMutationClient();
12 const calls: string[] = [];
13 const mutTest = client.define({
14 mutate: async () => {
15 calls.push("mutate");
16 await delay(100);
17 return "success";
18 },
19 describe: () => {
20 calls.push("describe");
21 return "Test the action";
22 },
23 describeResult: () => {
24 calls.push("describeResult");
25 return "Tested the action";
26 },
27 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
28 onRefetch(async () => void calls.push("refetch"));
29 onSuccess(async () => void calls.push("success"));
30 onRestore(async () => void calls.push("restore"));
31 calls.push("optimistic");
32 },
33 refetchOnSuccess,
34 });
35 mutTest.run();
36 await vi.advanceTimersByTimeAsync(50);
37 assertEquals(calls, [
38 "optimistic",
39 "mutate",
40 ]);
41 await vi.advanceTimersByTimeAsync(100);
42 assertEquals(
43 calls,
44 [
45 "optimistic",
46 "mutate",
47 "success",
48 refetchOnSuccess && "refetch",
49 "describeResult",
50 ].filter(Boolean),
51 );
52 assertEquals(errorMessages, []);
53 assertEquals(successMessages, ["Tested the action"]);
54});
55
56test("error case: describe is called before rollback", async () => {
57 vi.useFakeTimers();
58 const { client, errorMessages, successMessages } = createTestMutationClient();
59 const calls: string[] = [];
60 let optimisticState = false;
61
62 const mutTest = client.define({
63 mutate: async () => {
64 calls.push("mutate");
65 await delay(100);
66 throw new Error("API failed");
67 },
68 describe: () => {
69 calls.push("describe");
70 // This simulates reading optimistic state (e.g., get(...).isFollowing)
71 calls.push(`describe-state:${optimisticState}`);
72 return "follow user";
73 },
74 describeResult: () => {
75 calls.push("describeResult");
76 return "Followed user";
77 },
78 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
79 optimisticState = true;
80 onRefetch(async () => void calls.push("refetch"));
81 onSuccess(async () => void calls.push("success"));
82 onRestore(async () => {
83 calls.push("restore");
84 optimisticState = false;
85 });
86 calls.push("optimistic");
87 },
88 });
89
90 mutTest.run();
91 await vi.advanceTimersByTimeAsync(50);
92 assertEquals(calls, [
93 "optimistic",
94 "mutate",
95 ]);
96
97 await vi.advanceTimersByTimeAsync(100);
98 // CRITICAL: describe must be called BEFORE restore
99 // So the order should be: optimistic -> mutate -> describe -> restore -> refetch
100 const describeIndex = calls.indexOf("describe");
101 const restoreIndex = calls.indexOf("restore");
102
103 assertEquals(
104 describeIndex < restoreIndex,
105 true,
106 `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${calls.join(", ")}`,
107 );
108
109 // Verify describe was called with optimistic state still active
110 assertEquals(calls.includes("describe-state:true"), true, "describe should see optimistic state");
111
112 assertEquals(successMessages, []);
113 assertEquals(errorMessages.length, 1);
114 assertEquals(errorMessages[0].message, "Could not follow user: API failed");
115});
116
117test("error case with refetchOnSuccess=false still refetches", async () => {
118 vi.useFakeTimers();
119 const { client, errorMessages } = createTestMutationClient();
120 const calls: string[] = [];
121
122 const mutTest = client.define({
123 mutate: async () => {
124 calls.push("mutate");
125 await delay(100);
126 throw new Error("Failed");
127 },
128 describe: () => {
129 calls.push("describe");
130 return "test action";
131 },
132 describeResult: null,
133 optimistic: ({ onRefetch, onRestore }) => {
134 onRefetch(async () => void calls.push("refetch"));
135 onRestore(async () => void calls.push("restore"));
136 calls.push("optimistic");
137 },
138 refetchOnSuccess: false,
139 });
140
141 mutTest.run();
142 await vi.advanceTimersByTimeAsync(200);
143
144 // Even with refetchOnSuccess=false, errors should still trigger refetch
145 assertEquals(calls.includes("refetch"), true, "refetch should be called on error");
146 assertEquals(calls.includes("restore"), true, "restore should be called on error");
147 assertEquals(errorMessages.length, 1);
148});
149
150test("multiple mutations in sequence: ordering preserved", async () => {
151 vi.useFakeTimers();
152 const { client, successMessages } = createTestMutationClient();
153 const calls: string[] = [];
154
155 const mutTest = client.define({
156 mutate: async (id: number) => {
157 calls.push(`mutate-${id}`);
158 await delay(100);
159 return `result-${id}`;
160 },
161 describe: () => {
162 calls.push("describe");
163 return "test action";
164 },
165 describeResult: (ctx) => {
166 calls.push(`describeResult-${ctx.args[0]}`);
167 return `Completed ${ctx.args[0]}`;
168 },
169 optimistic: ({ args, onSuccess, onRestore }) => {
170 onSuccess(async () => void calls.push(`success-${args[0]}`));
171 onRestore(async () => void calls.push(`restore-${args[0]}`));
172 calls.push(`optimistic-${args[0]}`);
173 },
174 refetchOnSuccess: false,
175 });
176
177 mutTest.run(1);
178 mutTest.run(2);
179 mutTest.run(3);
180
181 await vi.advanceTimersByTimeAsync(50);
182 // Optimistic updates apply immediately, but mutate-1 starts right after optimistic-1
183 assertEquals(calls, [
184 "optimistic-1",
185 "mutate-1",
186 "optimistic-2",
187 "optimistic-3",
188 ]);
189
190 await vi.advanceTimersByTimeAsync(100);
191 // describeResult is called in a promise handler, so it happens after mutate-2 starts
192 assertEquals(calls, [
193 "optimistic-1",
194 "mutate-1",
195 "optimistic-2",
196 "optimistic-3",
197 "success-1",
198 "mutate-2",
199 "describeResult-1",
200 ]);
201
202 await vi.advanceTimersByTimeAsync(100);
203 assertEquals(calls, [
204 "optimistic-1",
205 "mutate-1",
206 "optimistic-2",
207 "optimistic-3",
208 "success-1",
209 "mutate-2",
210 "describeResult-1",
211 "success-2",
212 "mutate-3",
213 "describeResult-2",
214 ]);
215
216 await vi.advanceTimersByTimeAsync(100);
217 assertEquals(successMessages, ["Completed 1", "Completed 2", "Completed 3"]);
218});
219
220test("error in second mutation: first stays applied, second rolls back", async () => {
221 vi.useFakeTimers();
222 const { client, errorMessages, successMessages } = createTestMutationClient();
223 const calls: string[] = [];
224 let state = 0;
225
226 const mutTest = client.define({
227 mutate: async (id: number) => {
228 calls.push(`mutate-${id}`);
229 await delay(100);
230 if (id === 2) throw new Error("Second failed");
231 return `result-${id}`;
232 },
233 describe: (ctx) => {
234 calls.push(`describe-${ctx.args[0]}`);
235 calls.push(`describe-${ctx.args[0]}-state:${state}`);
236 return `action ${ctx.args[0]}`;
237 },
238 describeResult: (ctx) => {
239 calls.push(`describeResult-${ctx.args[0]}`);
240 return `Completed ${ctx.args[0]}`;
241 },
242 optimistic: ({ args, onSuccess, onRestore }) => {
243 state += 1;
244 onSuccess(async () => void calls.push(`success-${args[0]}`));
245 onRestore(async () => {
246 calls.push(`restore-${args[0]}`);
247 state -= 1;
248 });
249 calls.push(`optimistic-${args[0]}`);
250 },
251 refetchOnSuccess: false,
252 });
253
254 mutTest.run(1);
255 mutTest.run(2);
256
257 await vi.advanceTimersByTimeAsync(250);
258
259 // First mutation succeeds, second fails
260 // describe-2 should see state=2 (both optimistic updates applied)
261 // Then restore-2 rolls back only the second mutation
262 assertEquals(calls.includes("describe-2-state:2"), true, "describe for error should see optimistic state");
263 assertEquals(calls.includes("success-1"), true);
264 assertEquals(calls.includes("restore-2"), true);
265 assertEquals(successMessages, ["Completed 1"]);
266 assertEquals(errorMessages.length, 1);
267 assertEquals(errorMessages[0].message, "Could not action 2: Second failed");
268});
269
270test("onSuccess callback ordering relative to refetch", async () => {
271 vi.useFakeTimers();
272 const { client } = createTestMutationClient();
273 const calls: string[] = [];
274
275 const mutTest = client.define({
276 mutate: async () => {
277 calls.push("mutate");
278 await delay(100);
279 return "success";
280 },
281 describe: () => "test",
282 describeResult: () => "tested",
283 optimistic: ({ onRefetch, onSuccess }) => {
284 onRefetch(async () => {
285 calls.push("refetch-start");
286 await delay(50);
287 calls.push("refetch-end");
288 });
289 onSuccess(async () => {
290 calls.push("onSuccess-start");
291 await delay(30);
292 calls.push("onSuccess-end");
293 });
294 calls.push("optimistic");
295 },
296 refetchOnSuccess: true,
297 });
298
299 mutTest.run();
300 await vi.advanceTimersByTimeAsync(100);
301
302 // onSuccess should complete before refetch starts
303 const onSuccessEndIndex = calls.indexOf("onSuccess-end");
304 const refetchStartIndex = calls.indexOf("refetch-start");
305
306 assertEquals(
307 onSuccessEndIndex < refetchStartIndex,
308 true,
309 `onSuccess must complete before refetch starts. Order: ${calls.join(", ")}`,
310 );
311
312 await vi.advanceTimersByTimeAsync(100);
313 assertEquals(calls.includes("refetch-end"), true);
314});
315
316test("runWithOptions callbacks: onError called before global handler", async () => {
317 vi.useFakeTimers();
318 const { client, errorMessages } = createTestMutationClient();
319 const calls: string[] = [];
320
321 const mutTest = client.define({
322 mutate: async () => {
323 await delay(100);
324 throw new Error("Failed");
325 },
326 describe: () => {
327 calls.push("describe");
328 return "test";
329 },
330 describeResult: () => "tested",
331 optimistic: ({ onRestore }) => {
332 onRestore(() => calls.push("restore"));
333 calls.push("optimistic");
334 },
335 });
336
337 mutTest.runWithOptions({
338 onError: () => {
339 calls.push("onError");
340 },
341 onSettled: () => {
342 calls.push("onSettled");
343 },
344 });
345
346 await vi.advanceTimersByTimeAsync(200);
347
348 // onError suppresses global error handler
349 assertEquals(errorMessages, [], "onError should suppress global error handler");
350 assertEquals(calls.includes("onError"), true);
351 assertEquals(calls.includes("onSettled"), true);
352
353 // onError should be called before onSettled
354 const onErrorIndex = calls.indexOf("onError");
355 const onSettledIndex = calls.indexOf("onSettled");
356 assertEquals(onErrorIndex < onSettledIndex, true);
357});
358
359test("runWithOptions callbacks: onSuccess called before global handler", async () => {
360 vi.useFakeTimers();
361 const { client, successMessages } = createTestMutationClient();
362 const calls: string[] = [];
363
364 const mutTest = client.define({
365 mutate: async () => {
366 await delay(100);
367 return "result";
368 },
369 describe: () => "test",
370 describeResult: () => {
371 calls.push("describeResult");
372 return "tested";
373 },
374 optimistic: () => {
375 calls.push("optimistic");
376 },
377 refetchOnSuccess: false,
378 });
379
380 mutTest.runWithOptions({
381 onSuccess: () => {
382 calls.push("onSuccess");
383 },
384 onSettled: () => {
385 calls.push("onSettled");
386 },
387 });
388
389 await vi.advanceTimersByTimeAsync(150);
390
391 // onSuccess suppresses global success handler (describeResult won't add to successMessages)
392 assertEquals(successMessages, [], "onSuccess should suppress global success handler");
393 assertEquals(calls.includes("onSuccess"), true);
394 assertEquals(calls.includes("onSettled"), true);
395
396 // Order should be: onSuccess, onSettled, describeResult (describeResult still called but not reported)
397 const onSuccessIndex = calls.indexOf("onSuccess");
398 const onSettledIndex = calls.indexOf("onSettled");
399 assertEquals(onSuccessIndex < onSettledIndex, true);
400});
401
402test("optimistic update with no describeResult: no success message", async () => {
403 vi.useFakeTimers();
404 const { client, successMessages } = createTestMutationClient();
405 const calls: string[] = [];
406
407 const mutTest = client.define({
408 mutate: async () => {
409 await delay(100);
410 return "result";
411 },
412 describe: () => "test",
413 describeResult: null,
414 optimistic: ({ onSuccess }) => {
415 onSuccess(() => calls.push("success"));
416 calls.push("optimistic");
417 },
418 refetchOnSuccess: false,
419 });
420
421 mutTest.run();
422 await vi.advanceTimersByTimeAsync(150);
423
424 assertEquals(successMessages, [], "No success message when describeResult is null");
425 assertEquals(calls.includes("success"), true, "onSuccess callback still called");
426});
test/runWithOptions.test.tsx created+68
...@@ -0,0 +1,68 @@
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 optimistic: ({ onSuccess }) => {
20 onSuccess(() => {});
21 },
22 refetchOnSuccess: false,
23 describe: "Test the action",
24 describeResult: "Tested the action",
25 });
26
27 let renders: Array<{ status: string; result: string | undefined }> = [];
28 function TestComponent() {
29 const { runWithOptions, status, result } = useMutate(mutTest);
30 renders.push({ status, result });
31 return (
32 <button
33 data-testid="a"
34 onClick={() => {
35 runWithOptions({ onSuccessDataOnly: () => {} });
36 }}
37 >
38 button
39 </button>
40 );
41 }
42
43 render(<TestComponent />);
44 // initial state
45 assertEquals(renders, [{ status: "idle", result: undefined }]);
46 assertEquals(successMessages, []);
47 assertEquals(errorMessages, []);
48 renders = [];
49 vi.runAllTimers();
50
51 // mutation 1 - success
52 await act(() => user.click(screen.getByTestId("a")));
53 assertEquals(renders, [{ status: "mutating", result: undefined }]);
54 renders = [];
55 await act(async () => {
56 s.push("ok");
57 vi.advanceTimersByTime(100);
58 });
59 assertEquals(successMessages, ["Tested the action"]);
60 assertEquals(errorMessages, []);
61 assertEquals(renders, [{ status: "success", result: "ok" }]);
62 renders = [];
63 await act(async () => {
64 vi.advanceTimersByTime(10000);
65 });
66 assertEquals(renders, []);
67 renders = [];
68});
test/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/share.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1/* v8 ignore start -- @preserve */
1import { MutationClient } from "../src/client.ts";2import { MutationClient } from "../src/client.ts";
23
3export interface TestMutationClient {4export interface TestMutationClient {
test/snapshot.test.tsx created+231
...@@ -0,0 +1,231 @@
1import { useMutate } from "@clo/react-mutation";
2import { assert, 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("snapshot should skip no-op mutation", 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 let state = { value: "initial" };
16 let failed = false;
17 const mutUpdate = client.define({
18 mutate: async (newValue: string) => {
19 failed = true;
20 },
21 optimistic: ({ args: [newValue] }) => {
22 state.value = newValue;
23 },
24 snapshot: () => state.value,
25 describe: "Update value",
26 describeResult: "Updated value",
27 });
28
29 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
30 function TestComponent() {
31 const { run, status, isOptimisticData } = useMutate(mutUpdate);
32 renders.push({ status, isOptimisticData });
33 return (
34 <button data-testid="btn" onClick={() => run("initial")}>
35 update
36 </button>
37 );
38 }
39
40 render(<TestComponent />);
41 assertEquals(renders, [{ status: "idle", isOptimisticData: false }]);
42 renders = [];
43
44 // Click to run mutation with same value (no-op)
45 await act(() => user.click(screen.getByTestId("btn")));
46 vi.runAllTimers();
47
48 // Should skip the mutation and return to idle without calling API
49 assert(!failed);
50 assertEquals(state.value, "initial");
51 assertEquals(renders, []); // nothing changed
52 assertEquals(successMessages, []); // nothing happened
53 assertEquals(errorMessages, []);
54});
55
56test("snapshot should allow mutation when value changes", async () => {
57 vi.useFakeTimers({ shouldAdvanceTime: true });
58 const user = userEvent.setup({ delay: null });
59
60 const { client, successMessages, errorMessages } = createTestMutationClient();
61 const s = new IterableStream<string>();
62
63 let state = { value: "initial" };
64
65 const mutUpdate = client.define({
66 mutate: async (newValue: string) => {
67 return (await s.next()).value;
68 },
69 optimistic: ({ args: [newValue] }) => {
70 state.value = newValue;
71 },
72 snapshot: () => state.value,
73 describe: "Update value",
74 describeResult: "Updated value",
75 });
76
77 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
78 function TestComponent() {
79 const { run, status, isOptimisticData } = useMutate(mutUpdate);
80 renders.push({ status, isOptimisticData });
81 return (
82 <button data-testid="btn" onClick={() => run("changed")}>
83 update
84 </button>
85 );
86 }
87
88 render(<TestComponent />);
89 renders = [];
90
91 // Click to run mutation with different value
92 await act(() => user.click(screen.getByTestId("btn")));
93 assertEquals(state.value, "changed"); // Optimistic applied
94 assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]);
95 renders = [];
96
97 // Complete the mutation
98 await act(async () => {
99 s.push("ok");
100 vi.advanceTimersByTime(100);
101 });
102
103 assertEquals(renders, [{ status: "success", isOptimisticData: false }]);
104 assertEquals(successMessages, ["Updated value"]);
105 assertEquals(errorMessages, []);
106});
107
108test("debounced snapshot should skip when final state equals initial", async () => {
109 vi.useFakeTimers({ shouldAdvanceTime: true });
110 const user = userEvent.setup({ delay: null });
111
112 const { client, successMessages, errorMessages } = createTestMutationClient();
113 const s = new IterableStream<string>();
114
115 let state = { value: "initial" };
116
117 const mutations: string[] = [];
118 const mutUpdate = client.define({
119 mutate: async (newValue: string) => {
120 mutations.push(newValue);
121 },
122 optimistic: ({ args: [newValue] }) => {
123 state.value = newValue;
124 },
125 snapshot: () => state.value,
126 debounceMs: 500,
127 refetchOnSuccess: false,
128 describe: "Update value",
129 describeResult: "Updated value",
130 });
131
132 let renders: Array<{ status: string }> = [];
133 function TestComponent() {
134 const { run, status } = useMutate(mutUpdate);
135 renders.push({ status });
136 return (
137 <>
138 <button data-testid="a" onClick={() => run("changed")}>A</button>
139 <button data-testid="b" onClick={() => run("initial")}>B</button>
140 </>
141 );
142 }
143
144 render(<TestComponent />);
145 assertEquals(renders, [{ status: "idle" }]);
146 renders = [];
147
148 // First call - change value
149 await act(() => user.click(screen.getByTestId("a")));
150 assertEquals(state.value, "changed");
151 assertEquals(renders, []); // Debounced mutations don't show mutating, no update
152 assertEquals(mutations, []);
153 renders = [];
154
155 // Second call - revert to initial (no-op overall)
156 await act(() => user.click(screen.getByTestId("b")));
157 assertEquals(state.value, "initial"); // Should be rolled back to initial
158 assertEquals(renders, []); // Still idle
159 assertEquals(successMessages, []);
160 assertEquals(errorMessages, []);
161 assertEquals(mutations, []);
162
163 await act(() => vi.runAllTimers());
164 assertEquals(successMessages, []);
165 assertEquals(errorMessages, []);
166 assertEquals(mutations, []);
167});
168
169test("debounced snapshot should mutate when final differs from initial", async () => {
170 vi.useFakeTimers({ shouldAdvanceTime: true });
171 const user = userEvent.setup({ delay: null });
172
173 const { client, successMessages, errorMessages } = createTestMutationClient();
174 const s = new IterableStream<string>();
175
176 let state = { value: "initial" };
177
178 const mutUpdate = client.define({
179 mutate: async (newValue: string) => {
180 return (await s.next()).value;
181 },
182 optimistic: ({ args: [newValue] }) => {
183 state.value = newValue;
184 },
185 snapshot: () => state.value,
186 debounceMs: 500,
187 refetchOnSuccess: false,
188 describe: "Update value",
189 describeResult: "Updated value",
190 });
191
192 let renders: Array<{ status: string; result: string | undefined }> = [];
193 function TestComponent() {
194 const { run, status, result } = useMutate(mutUpdate);
195 renders.push({ status, result });
196 return (
197 <>
198 <button data-testid="a" onClick={() => run("temp")}>A</button>
199 <button data-testid="b" onClick={() => run("final")}>B</button>
200 </>
201 );
202 }
203
204 render(<TestComponent />);
205 renders = [];
206
207 // First call
208 await act(() => user.click(screen.getByTestId("a")));
209 assertEquals(state.value, "temp");
210 renders = [];
211
212 // Second call - different from initial
213 await act(() => user.click(screen.getByTestId("b")));
214 assertEquals(state.value, "final");
215 renders = [];
216
217 // Wait for debounce
218 await act(async () => {
219 vi.advanceTimersByTime(500);
220 });
221 assertEquals(renders, [{ status: "mutating", result: undefined }]);
222 renders = [];
223
224 // Complete mutation
225 await act(async () => {
226 s.push("ok");
227 vi.advanceTimersByTime(100);
228 });
229 assertEquals(renders, [{ status: "success", result: "ok" }]);
230 assertEquals(successMessages, []);
231});