authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 21:04:15-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:05:20-08:00
log27b9bdd4d52caa1b142458c5dd1437c75b69390c
tree7ff6cec4b9ea88a828dc01957f42bacaa8da7f6e
parent005380d2610353262fa5acd170352dbbf3e4e6c2
signaturelock-open Commit is signed but in an unrecognized format.

chore: delete a ton of shit that i dont trust or need


10 files changed, 664 insertions(+), 6326 deletions(-)

src/blocking.ts deleted-662
...@@ -1,662 +0,0 @@
1import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
3import type { MutationClientConfig } from "./client.ts";
4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
5
6/**
7 * Argument to `defineBlocking`.
8 * @template Args - the parameters to the mutation
9 * @template Result - the result of the API call
10 * @template Config - global values and helpers from `MutationContext`
11 */
12export interface MutationOptions<
13 Args extends unknown[],
14 Result,
15 Config extends MutationClientConfig,
16> {
17 /**
18 * This function is only responsible for performing the underlying API call,
19 * syncronizing the optimistic state with reality. Throw on failure. A rest
20 * params type is used to allow type inference. Place this function first to
21 * ensure TypeScript correctly infers the argument type for the rest of the
22 * functions.
23 *
24 * In practice, optimistic context is never needed in this function, but it
25 * is provided as the `this` value if you truly desire it.
26 */
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
28 /**
29 * Used in error messages and debug tools.
30 * Phrase it considering the template `Failed to ${describe(...)}`
31 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);
33 /**
34 * Used in success messages.
35 * Phrase it as a complete success message, e.g., "Deleted item successfully"
36 */
37 describeResult:
38 | string
39 | ((context: Config["context"] & { args: Args; result: Result }) => string)
40 | null;
41 /**
42 * Specifying the optimistic strategy is required. To disable, pass an empty
43 * function with a comment to document why it isn't needed.
44 */
45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;
48 /**
49 * If the optimistic updator function is perfect, then this may be set to false.
50 * @default true
51 */
52 refetchOnSuccess?: boolean;
53 /**
54 * A key to associate related items. For example, returning a user ID. If
55 * specifying, then all mutations of the same key will evaluate in serial,
56 * but optimistic updates will apply instantly.
57 */
58 key?: (context: Config["context"] & { args: Args }) => string | string[];
59 /**
60 * Enable debouncing with "last call wins" behavior. When rapid calls arrive,
61 * the previous optimistic update is rolled back and the new one applied.
62 *
63 * All pending promises resolve with the final result.
64 */
65 debounceMs?: number;
66}
67
68export type OptimisticContext<
69 Args extends unknown[],
70 Result,
71 Config extends MutationClientConfig,
72> = Config["context"] & {
73 args: Args;
74 helpers: Config["optimisticHelpers"];
75 /** Add an event listener to roll back the update */
76 onRestore: (cb: () => void) => void;
77 /** Add an event listener to apply `Result` to the store. */
78 onSuccess: (cb: (result: Result) => void) => void;
79 /** Add an event listener to refetch data after mutation. */
80 onRefetch: (cb: () => Promise<void>) => void;
81};
82
83interface PendingDebouncedState<Args extends unknown[], Result> {
84 /** Arguments from the most recent call */
85 args: Args;
86 /** Number of rollbacks the most recent call added */
87 rollbackCount: number;
88 /** All pending promises from all superseded calls */
89 pending: Array<{
90 resolve: (result: Result) => void;
91 reject: (error: unknown) => void;
92 }>;
93 /** Success callbacks from the most recent call */
94 onSuccess: Array<(result: Result) => void>;
95}
96
97interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
98 listeners: Set<(update: MutationEvent<Result>) => void>;
99 status: "idle" | "waiting" | "mutating" | "refetching";
100 rollbacks: Array<() => void>;
101 refetches: Array<() => Promise<void>>;
102 queue: Array<Item<Args, Result>>;
103 // Shared optimistic helpers instance for the channel
104 helpers: OptimisticHelpers | null;
105 // Debounce state (only used if debounce option is set)
106 debounceTimer: ReturnType<typeof setTimeout> | null;
107 pendingDebounced: PendingDebouncedState<Args, Result> | null;
108}
109
110interface Item<Args extends unknown[], Result> {
111 args: Args;
112 rollbacks: number;
113 onSuccess: Array<(result: Result) => void>;
114 resolve: (result: Result) => void;
115 reject: (error: unknown) => void;
116}
117
118export class BlockingMutation<
119 Args extends unknown[],
120 Result,
121 Config extends MutationClientConfig,
122> implements Mutation<Args, Result> {
123 #options: MutationOptions<Args, Result, Config>;
124 #client: MutationClientFromConfig<Config>;
125 #channels: Map<
126 string,
127 Channel<Args, Result, Config["optimisticHelpers"]>
128 > = new Map();
129 client: MutationClientFromConfig<Config>;
130
131 constructor(
132 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
133 options: MutationOptions<Args, Result, Config>,
134 ) {
135 this.#options = options;
136 this.#client = client;
137 this.client = client;
138 }
139
140 key(args: Args) {
141 const k = this.#options.key?.({ ...this.#client.context, args })
142 ?? "shared";
143 return JSON.stringify(k);
144 }
145
146 #getOrPutChannel(key: string) {
147 let channel = this.#channels.get(key);
148 if (!channel) {
149 const rollbacks: Array<() => []> = [];
150 channel = {
151 listeners: new Set(),
152 status: "idle",
153 rollbacks,
154 refetches: [],
155 queue: [],
156 helpers: null,
157 debounceTimer: null,
158 pendingDebounced: null,
159 };
160 this.#channels.set(key, channel);
161 }
162 return channel;
163 }
164
165 subscribe(
166 key: string,
167 cb: (update: MutationEvent<Result>) => void,
168 ): () => void {
169 const channel = this.#getOrPutChannel(key);
170 channel.listeners.add(cb);
171 return () => channel?.listeners.delete(cb);
172 }
173
174 #notify(
175 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
176 status: MutationEvent<Result>["status"],
177 result: Result | null = null,
178 error: unknown = null,
179 ) {
180 const event: MutationEvent<Result> = { status, result, error };
181 channel.listeners.forEach((cb) => cb(event));
182 }
183
184 #setIdle(
185 key: string,
186 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
187 ) {
188 // Check if there are pending debounced calls waiting
189 if (channel.pendingDebounced !== null) {
190 // Stay in waiting state
191 channel.status = "waiting";
192 this.#notify(channel, "waiting", null, null);
193 } else {
194 // Normal idle transition
195 channel.status = "idle";
196 // Discard any unconsumed refetch callbacks
197 channel.refetches = [];
198 this.#notify(channel, "idle", null, null);
199 // Clean up the channel if there are no listeners
200 if (channel.listeners.size === 0) {
201 // Clear any pending timers before deleting the channel
202 if (channel.debounceTimer !== null) {
203 clearTimeout(channel.debounceTimer);
204 channel.debounceTimer = null;
205 }
206 this.#channels.delete(key);
207 }
208 }
209 }
210
211 describe(...args: Args): string {
212 const { describe } = this.#options;
213 return typeof describe === "function"
214 ? describe({ ...this.#client.context, args })
215 : describe;
216 }
217
218 describeResult(args: Args, result: Result): string | undefined {
219 const { describeResult } = this.#options;
220 if (describeResult === null) return undefined;
221 return typeof describeResult === "function"
222 ? describeResult({ ...this.#client.context, args, result })
223 : describeResult;
224 }
225
226 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
227 run(...args: Args) {
228 this.runWithOptions(...args, {});
229 }
230
231 /** Calls the mutation with custom handlers that can suppress global handlers. */
232 runWithOptions(...array: [...Args, RunOptions<Result>]): Promise<Result> {
233 if (!this.#client.enabled) {
234 throw new Error(
235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
236 );
237 }
238
239 const args = array.slice() as Args;
240 const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args
241 .pop() as RunOptions<Result>;
242 const suppressGlobalSuccess = onSuccess !== undefined;
243 const suppressGlobalError = onError !== undefined;
244
245 const promise = this.#runAsPromiseWithOptions(args, { onRestore });
246 promise.then((result) => {
247 // Call user handlers
248 onSuccess?.(result);
249 onSuccessDataOnly?.(result);
250 onSettled?.({ status: "success", result });
251
252 // Call global handler unless suppressed
253 if (!suppressGlobalSuccess) {
254 const message = this.describeResult(args, result);
255 if (message && this.#client.reportSuccess) {
256 this.#client.reportSuccess(message);
257 }
258 }
259 }).catch((error) => {
260 // Call user handlers
261 onError?.(error);
262 onSettled?.({ status: "error", error });
263
264 // Call global handler unless suppressed
265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
267 this.#client.reportError(message, error);
268 }
269 });
270
271 return promise;
272 }
273
274 /** Calls the mutation, treating the errors as promise rejection. */
275 runAsPromise(...args: Args): Promise<Result> {
276 return this.#runAsPromiseWithOptions(args, {});
277 }
278
279 #runAsPromiseWithOptions(
280 args: Args,
281 { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">,
282 ): Promise<Result> {
283 if (!this.#client.enabled) {
284 throw new Error(
285 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
286 );
287 }
288 const key = this.key(args);
289 const channel = this.#getOrPutChannel(key);
290
291 // Check if debouncing is enabled
292 if (this.#options.debounceMs !== undefined) {
293 return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true);
294 }
295
296 // Create shared optimistic helpers instance for the channel if it doesn't exist
297 if (channel.helpers === null) {
298 const onRefetch = (cb: () => Promise<void>) => {
299 channel.refetches.push(cb);
300 };
301
302 channel.helpers = this.#client.getOptimisticHelpers({
303 onRestore: (cb: () => void) => {
304 channel.rollbacks.push(cb);
305 },
306 onRefetch,
307 });
308 }
309
310 const onSuccess: Array<(result: Result) => void> = [];
311 let expired = false;
312 let rollbacks = 0;
313 const onRestore = (cb: () => void) => {
314 if (expired) {
315 throw new Error(
316 "Can only call onRestore from within the optimistic update function.",
317 );
318 }
319 channel.rollbacks.push(cb);
320 rollbacks += 1;
321 };
322
323 // Register user's onRestore callback if provided
324 if (userOnRestore) {
325 channel.rollbacks.push(userOnRestore);
326 rollbacks += 1;
327 }
328
329 try {
330 this.#options.optimistic({
331 args,
332 helpers: channel.helpers,
333 onRestore,
334 onSuccess(cb) {
335 if (expired) {
336 throw new Error(
337 "Can only call onSuccess from within the optimistic update function.",
338 );
339 }
340 onSuccess.push(cb);
341 },
342 onRefetch(cb) {
343 if (expired) {
344 throw new Error(
345 "Can only call onRefetch from within the optimistic update function.",
346 );
347 }
348 channel.refetches.push(cb);
349 },
350 });
351 } catch (error) {
352 expired = true;
353 let next;
354 while (
355 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
356 ) {
357 next();
358 }
359 return Promise.reject(error);
360 }
361 expired = true;
362
363 const { promise, resolve, reject } = Promise.withResolvers<Result>();
364 channel.queue.push({
365 args,
366 rollbacks,
367 onSuccess,
368 resolve,
369 reject,
370 });
371
372 if (channel.status === "idle") {
373 this.#executeNext(key, channel);
374 }
375
376 return promise;
377 }
378
379 #executeNext(
380 key: string,
381 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
382 ) {
383 const item = channel.queue.shift();
384 if (!item) {
385 this.#setIdle(key, channel);
386 return;
387 }
388
389 const { args, onSuccess, resolve, reject } = item;
390 channel.status = "mutating";
391 this.#notify(channel, "mutating");
392
393 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
394 // remove rollbacks and apply optimistic success handlers
395 channel.rollbacks.splice(0, item.rollbacks);
396 onSuccess.forEach((cb) => cb(result));
397
398 if (this.#options.refetchOnSuccess !== false) {
399 channel.status = "refetching";
400 this.#notify(channel, "refetching", result);
401 // Call refetch and all refetch callbacks in parallel
402 const refetchCallbacks = channel.refetches.splice(0);
403 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
404 (results) => {
405 // Report any errors from refetch or callbacks
406 results.forEach((result) => {
407 if (result.status === "rejected") {
408 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
409 this.#client.reportError(message, result.reason);
410 }
411 });
412 },
413 ).finally(() => {
414 this.#executeNext(key, channel);
415 });
416 } else {
417 // Discard refetch callbacks if refetchOnSuccess is false
418 channel.refetches = [];
419 this.#executeNext(key, channel);
420 }
421 resolve(result);
422 }, (error) => {
423 // if an error happens, then every rollback is called in reverse order
424 let next;
425 while (next = channel.rollbacks.pop()) next();
426
427 // Cancel all remaining items in the channel
428 const remainingItems = channel.queue.splice(0);
429 remainingItems.forEach((queuedItem) => {
430 queuedItem.reject(error);
431 });
432
433 // Notify listeners of the error
434 this.#notify(channel, "mutating", null, error);
435
436 // Refetch to restore correct state
437 channel.status = "refetching";
438 this.#notify(channel, "refetching", null, error);
439 // Call refetch and all refetch callbacks in parallel
440 const refetchCallbacks = channel.refetches.splice(0);
441 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => {
442 // Report any errors from refetch or callbacks
443 results.forEach((result) => {
444 if (result.status === "rejected") {
445 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
446 this.#client.reportError(message, result.reason);
447 }
448 });
449 }).finally(() => {
450 this.#setIdle(key, channel);
451 });
452
453 reject(error);
454 });
455 }
456
457 #runDebouncedAndReturn(
458 args: Args,
459 key: string,
460 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
461 userOnRestore?: () => void,
462 fromRunWithOptions = false,
463 ): Promise<Result> {
464 // If there's a pending debounced call, roll it back
465 if (channel.pendingDebounced) {
466 this.#rollbackPendingDebounced(channel);
467 }
468
469 // Create shared helpers if needed (same as current implementation)
470 if (channel.helpers === null) {
471 const onRefetch = (cb: () => Promise<void>) => {
472 channel.refetches.push(cb);
473 };
474 channel.helpers = this.#client.getOptimisticHelpers({
475 onRestore: (cb: () => void) => {
476 channel.rollbacks.push(cb);
477 },
478 onRefetch,
479 });
480 }
481
482 // Apply optimistic update (same logic as current runAndReturn)
483 const onSuccess: Array<(result: Result) => void> = [];
484 let expired = false;
485 let rollbacks = 0;
486 const onRestore = (cb: () => void) => {
487 if (expired) {
488 throw new Error(
489 "Can only call onRestore from within the optimistic update function.",
490 );
491 }
492 channel.rollbacks.push(cb);
493 rollbacks += 1;
494 };
495
496 // Register user's onRestore callback if provided
497 if (userOnRestore) {
498 channel.rollbacks.push(userOnRestore);
499 rollbacks += 1;
500 }
501
502 try {
503 this.#options.optimistic({
504 args,
505 helpers: channel.helpers,
506 onRestore,
507 onSuccess(cb) {
508 if (expired) {
509 throw new Error(
510 "Can only call onSuccess from within the optimistic update function.",
511 );
512 }
513 onSuccess.push(cb);
514 },
515 onRefetch(cb) {
516 if (expired) {
517 throw new Error(
518 "Can only call onRefetch from within the optimistic update function.",
519 );
520 }
521 channel.refetches.push(cb);
522 },
523 });
524 } catch (error) {
525 expired = true;
526 // Roll back the rollbacks we just added
527 let next;
528 while (
529 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
530 ) {
531 next();
532 }
533 return Promise.reject(error);
534 }
535 expired = true;
536
537 // Create promise for this call
538 const { promise, resolve, reject } = Promise.withResolvers<Result>();
539
540 // Store or update pending debounced state
541 if (channel.pendingDebounced === null) {
542 // First debounced call
543 channel.pendingDebounced = {
544 args,
545 rollbackCount: rollbacks,
546 pending: [{ resolve, reject }],
547 onSuccess,
548 };
549
550 // Set status to waiting
551 channel.status = "waiting";
552 this.#notify(channel, "waiting");
553 } else {
554 // Subsequent debounced call - update state
555 channel.pendingDebounced.args = args;
556 channel.pendingDebounced.rollbackCount = rollbacks;
557 channel.pendingDebounced.pending.push({ resolve, reject });
558 channel.pendingDebounced.onSuccess = onSuccess;
559 // Status stays "waiting"
560 }
561
562 // Clear existing timer
563 if (channel.debounceTimer !== null) {
564 clearTimeout(channel.debounceTimer);
565 }
566
567 // Start new timer
568 channel.debounceTimer = setTimeout(() => {
569 this.#enqueueDebouncedCall(key, channel);
570 }, this.#options.debounceMs);
571
572 return promise;
573 }
574
575 #rollbackPendingDebounced(
576 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
577 ) {
578 if (!channel.pendingDebounced) return;
579
580 const { rollbackCount } = channel.pendingDebounced;
581
582 // Roll back this call's optimistic updates (in reverse order)
583 // Remove from the end of the rollbacks array
584 for (let i = 0; i < rollbackCount; i++) {
585 const rollback = channel.rollbacks.pop();
586 if (rollback) rollback();
587 }
588
589 // Note: We do NOT reject the promises here
590 // They will all resolve when the final call completes
591 }
592
593 #enqueueDebouncedCall(
594 key: string,
595 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
596 ) {
597 // Clear timer
598 channel.debounceTimer = null;
599
600 // Safety check
601 if (!channel.pendingDebounced) {
602 this.#setIdle(key, channel);
603 return;
604 }
605
606 const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced;
607 channel.pendingDebounced = null;
608
609 // Check if there are any listeners at time of enqueue
610 const hasListeners = channel.listeners.size > 0;
611
612 // Create wrapper resolve/reject that resolves ALL pending promises
613 const {
614 promise: wrapperPromise,
615 resolve: wrapperResolve,
616 reject: wrapperReject,
617 } = Promise.withResolvers<Result>();
618
619 // Resolve/reject pending promises and add global handler logic for execution-time checks
620 wrapperPromise.then(
621 (result) => {
622 // Resolve all pending promises
623 pending.forEach((p) => p.resolve(result));
624
625 // Check if there are any listeners at execution time
626 const hasListeners = channel.listeners.size > 0;
627 if (!hasListeners) {
628 const message = this.describeResult(args, result);
629 if (message && this.#client.reportSuccess) {
630 this.#client.reportSuccess(message);
631 }
632 }
633 },
634 (error) => {
635 // Reject all pending promises
636 pending.forEach((p) => p.reject(error));
637
638 // Check if there are any listeners at execution time
639 const hasListeners = channel.listeners.size > 0;
640 if (!hasListeners) {
641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
642 this.#client.reportError(message, error);
643 }
644 },
645 );
646
647 // Add to queue (same structure as regular blocking mutation)
648 channel.queue.push({
649 args,
650 rollbacks: rollbackCount,
651 onSuccess,
652 resolve: wrapperResolve,
653 reject: wrapperReject,
654 });
655
656 // If queue was idle/waiting, start execution
657 if (channel.status === "idle" || channel.status === "waiting") {
658 this.#executeNext(key, channel);
659 }
660 // Otherwise, it will execute when the current item finishes
661 }
662}
src/client.ts+1-26
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { BlockingMutation, type MutationOptions } from "./blocking.ts";
2import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
2import { BlockingMutation, type MutationOptions } from "./mutation.ts";
3import type { Mutation } from "./types.ts";3import type { Mutation } from "./types.ts";
44
5export interface MutationClientConfig {5export interface MutationClientConfig {
...@@ -79,29 +79,4 @@ export class MutationClient<...@@ -79,29 +79,4 @@ export class MutationClient<
79 { context: Context; optimisticHelpers: OptimisticHelpers }79 { context: Context; optimisticHelpers: OptimisticHelpers }
80 >(this, options);80 >(this, options);
81 }81 }
82
83 /**
84 * **This API is experimental and subject to alteration or removal.**
85 *
86 * Define a batched mutation. Each call to the mutation applies new optimistic
87 * state, and after a debounce or throttle, the new optimistic state is
88 * committed to the API. UI never shows a pending state for debounced mutations. This
89 * works great for auto-saving input fields, follow buttons, and is preferred
90 * whenever possible.
91 */
92 defineBatched<const Args extends unknown[], Result, Optimistic>(
93 options: DebouncedMutationOptions<
94 Args,
95 Result,
96 Optimistic,
97 { context: Context; optimisticHelpers: OptimisticHelpers }
98 >,
99 ): Mutation<Args, Result> {
100 return new DebouncedMutation<
101 Args,
102 Result,
103 Optimistic,
104 { context: Context; optimisticHelpers: OptimisticHelpers }
105 >(this, options);
106 }
107}82}
src/debounced.ts deleted-609
...@@ -1,609 +0,0 @@
1import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
3import type { MutationClientConfig } from "./client.ts";
4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
5
6export interface DebouncedMutationOptions<
7 Args extends unknown[],
8 Result,
9 Optimistic,
10 Config extends MutationClientConfig,
11> {
12 /**
13 * Update the UI for one call to the optimistic function.
14 * A rest params type is used to allow type inference. Place this function first to
15 * ensure TypeScript correctly infers the argument type for the rest of the functions.
16 */
17 optimistic: (
18 context: DebouncedOptimisticContext<Config>,
19 ...args: Args
20 ) => void;
21 /**
22 * Retrieve the current/optimistic value of the mutation. When this returns
23 * the same thing as when the mutation started, it means that `mutate` does
24 * not need to be called since the data is the same.
25 *
26 * Don't snapshot unrelated state that this mutation isn't concerned with.
27 */
28 getValue: (context: Config["context"] & { args: Args }) => Optimistic;
29
30 /**
31 * @default "debounce"
32 */
33 mode?: "debounce" | "throttle";
34 /**
35 * Milliseconds
36 * @default 200
37 */
38 time?: number;
39
40 /** A key to associate debounced items. For example, returning a user ID */
41 key: (
42 context: Config["context"] & { args: NoInfer<Args> },
43 ) => string | string[];
44
45 /**
46 * Commit the optimistic state. Throw on failure.
47 */
48 commit: (
49 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>,
50 ) => Promise<Result>;
51 /**
52 * Used in error messages and debug tools.
53 * "Failed to {action}"
54 */
55 describe:
56 | string
57 | ((
58 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>,
59 ) => string);
60 /**
61 * Used in success messages.
62 * Phrase it as a complete success message: "Renamed item successfully"
63 */
64 describeResult:
65 | string
66 | ((
67 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config> & {
68 result: Result;
69 },
70 ) => string)
71 | null;
72 /**
73 * Refetch all of the data this mutation could have affected.
74 */
75 refetch?: (
76 context: Config["context"] & { args: NoInfer<Args> },
77 ) => Promise<void>;
78
79 refetchOnSuccess?: boolean;
80}
81
82export type DebouncedOptimisticContext<Config extends MutationClientConfig> =
83 & Config["context"]
84 & {
85 /** Add an event listener to roll back the update */
86 onRestore: (cb: () => void) => void;
87 helpers: Config["optimisticHelpers"];
88 };
89
90export type DebouncedCommitContext<
91 Args,
92 Optimistic,
93 Config extends MutationClientConfig,
94> = Config["context"] & {
95 /** One of the arguments. Use this only to extract the shared key */
96 args: Args;
97 /** The initial snapshot */
98 initial: Optimistic;
99 /** The compared snapshot */
100 current: Optimistic;
101};
102
103interface DebouncedChannel<
104 Args extends unknown[],
105 Result,
106 Optimistic,
107 OptimisticHelpers,
108> {
109 listeners: Set<(update: MutationEvent<Result>) => void>;
110 status: "idle" | "waiting" | "mutating" | "refetching";
111
112 // Snapshot before first call in current debounced run
113 initial: Optimistic | null;
114 // First args in debounced run (used for commit/describe/getValue)
115 firstArgs: Args | null;
116 rollbacks: Array<() => void>;
117 refetches: Array<() => Promise<void>>;
118 timer: ReturnType<typeof setTimeout> | null;
119
120 // Track last commit time for throttle mode
121 lastCommitTime: number;
122
123 // Shared optimistic helpers instance for the current debounced run
124 helpers: OptimisticHelpers | null;
125
126 // Pending promises from callers in current debounced run
127 pending: Array<{
128 args: Args;
129 resolve: (result: Result) => void;
130 reject: (error: unknown) => void;
131 reportSuccessGlobally?: boolean;
132 }>;
133}
134
135export class DebouncedMutation<
136 Args extends unknown[],
137 Result,
138 Optimistic,
139 Config extends MutationClientConfig,
140> implements Mutation<Args, Result> {
141 #options: DebouncedMutationOptions<Args, Result, Optimistic, Config>;
142 #client: MutationClientFromConfig<Config>;
143 #channels: Map<
144 string,
145 DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]>
146 > = new Map();
147 client: MutationClientFromConfig<Config>;
148
149 constructor(
150 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
151 options: DebouncedMutationOptions<Args, Result, Optimistic, Config>,
152 ) {
153 this.#options = options;
154 this.#client = client;
155 this.client = client;
156 }
157
158 key(args: Args): string {
159 const k = this.#options.key({ ...this.#client.context, args });
160 return JSON.stringify(k);
161 }
162
163 #getOrPutChannel(
164 key: string,
165 ): DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]> {
166 let channel = this.#channels.get(key);
167 if (!channel) {
168 channel = {
169 listeners: new Set(),
170 status: "idle",
171 initial: null,
172 firstArgs: null,
173 rollbacks: [],
174 refetches: [],
175 timer: null,
176 lastCommitTime: 0,
177 helpers: null,
178 pending: [],
179 };
180 this.#channels.set(key, channel);
181 }
182 return channel;
183 }
184
185 subscribe(
186 key: string,
187 cb: (update: MutationEvent<Result>) => void,
188 ): () => void {
189 const channel = this.#getOrPutChannel(key);
190 channel.listeners.add(cb);
191 return () => channel?.listeners.delete(cb);
192 }
193
194 #notify(
195 channel: DebouncedChannel<
196 Args,
197 Result,
198 Optimistic,
199 Config["optimisticHelpers"]
200 >,
201 status: MutationEvent<Result>["status"],
202 result: Result | null = null,
203 error: unknown = null,
204 ) {
205 const event: MutationEvent<Result> = { status, result, error };
206 channel.listeners.forEach((cb) => cb(event));
207 }
208
209 #setIdle(
210 key: string,
211 channel: DebouncedChannel<
212 Args,
213 Result,
214 Optimistic,
215 Config["optimisticHelpers"]
216 >,
217 ) {
218 channel.status = "idle";
219 this.#notify(channel, "idle", null, null);
220 // Clean up the channel if there are no listeners
221 if (channel.listeners.size === 0) {
222 this.#channels.delete(key);
223 }
224 }
225
226 #resetDebouncedState(
227 channel: DebouncedChannel<
228 Args,
229 Result,
230 Optimistic,
231 Config["optimisticHelpers"]
232 >,
233 ) {
234 channel.initial = null;
235 channel.firstArgs = null;
236 channel.rollbacks = [];
237 channel.refetches = [];
238 channel.helpers = null;
239 channel.pending = [];
240 if (channel.timer !== null) {
241 clearTimeout(channel.timer);
242 channel.timer = null;
243 }
244 }
245
246 describe(...args: Args): string {
247 const { describe } = this.#options;
248 if (typeof describe === "function") {
249 // For describe, we need initial/current but may not have them yet
250 // Use placeholder values when called outside of commit context
251 return describe({
252 ...this.#client.context,
253 args,
254 initial: null as unknown as Optimistic,
255 current: null as unknown as Optimistic,
256 });
257 }
258 return describe;
259 }
260
261 // Not available for debounced mutations - success reporting happens during commit
262 describeResult: null = null;
263
264 #describeResult(
265 args: Args,
266 initial: Optimistic,
267 current: Optimistic,
268 result: Result,
269 ): string | undefined {
270 const { describeResult } = this.#options;
271 if (describeResult === null) return undefined;
272 return typeof describeResult === "function"
273 ? describeResult({
274 ...this.#client.context,
275 args,
276 initial,
277 current,
278 result,
279 })
280 : describeResult;
281 }
282
283 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
284 run(...args: Args): void {
285 if (!this.#client.enabled) {
286 throw new Error(
287 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
288 );
289 }
290 this.#runAndReturn(args, true, undefined).catch((error) => {
291 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
292 this.#client.reportError(message, error);
293 });
294 }
295
296 runWithOptions(...array: [...args: Args, options: RunOptions<Result>]): void {
297 if (!this.#client.enabled) {
298 throw new Error(
299 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
300 );
301 }
302 const args = array.slice() as Args;
303 const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args
304 .pop() as RunOptions<Result>;
305 const suppressGlobalSuccess = onSuccess !== undefined;
306 const suppressGlobalError = onError !== undefined;
307
308 const promise = this.#runAndReturn(args, !suppressGlobalSuccess, onRestore);
309
310 promise.then((result) => {
311 // Call user handlers
312 onSuccess?.(result);
313 onSuccessDataOnly?.(result);
314 onSettled?.({ status: "success", result });
315 }).catch((error) => {
316 // Call user handlers
317 onError?.(error);
318 onSettled?.({ status: "error", error });
319
320 // Call global error handler unless suppressed
321 if (!suppressGlobalError) {
322 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
323 this.#client.reportError(message, error);
324 }
325 });
326 }
327
328 /** Calls the mutation, treating the errors as promise rejection. */
329 runAsPromise(...args: Args): Promise<Result> {
330 if (!this.#client.enabled) {
331 throw new Error(
332 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
333 );
334 }
335 return this.#runAndReturn(args, false, undefined);
336 }
337
338 #runAndReturn(
339 args: Args,
340 reportSuccessGlobally: boolean,
341 userOnRestore?: () => void,
342 ): Promise<Result> {
343 const key = this.key(args);
344 const channel = this.#getOrPutChannel(key);
345
346 // If this is the first call in the debounced run, take a snapshot and create shared helpers
347 if (channel.initial === null) {
348 channel.initial = this.#options.getValue({
349 ...this.#client.context,
350 args,
351 });
352 channel.firstArgs = args;
353
354 // Create shared onRefetch handler for the debounced run
355 const onRefetch = (cb: () => Promise<void>) => {
356 channel.refetches.push(cb);
357 };
358
359 // Create shared optimistic helpers instance for this debounced run
360 channel.helpers = this.#client.getOptimisticHelpers({
361 onRestore: (cb: () => void) => {
362 channel.rollbacks.push(cb);
363 },
364 onRefetch,
365 });
366 }
367
368 // Apply optimistic update
369 let expired = false;
370 const onRestore = (cb: () => void) => {
371 if (expired) {
372 throw new Error(
373 "Can only call onRestore from within the optimistic update function.",
374 );
375 }
376 channel.rollbacks.push(cb);
377 };
378
379 // Register user's onRestore callback if provided
380 if (userOnRestore) {
381 channel.rollbacks.push(userOnRestore);
382 }
383
384 try {
385 this.#options.optimistic(
386 {
387 ...this.#client.context,
388 onRestore,
389 helpers: channel.helpers!,
390 },
391 ...args,
392 );
393 } catch (error) {
394 expired = true;
395 // We don't know how many were added, so we can't do partial rollback easily
396 // For simplicity, rollback everything and reject
397 let next;
398 while ((next = channel.rollbacks.pop())) next();
399 this.#resetDebouncedState(channel);
400 return Promise.reject(error);
401 }
402 expired = true;
403
404 // Create promise for this caller
405 const { promise, resolve, reject } = Promise.withResolvers<Result>();
406 channel.pending.push({ args, resolve, reject, reportSuccessGlobally });
407
408 // Set status to waiting and notify
409 if (channel.status === "idle") {
410 channel.status = "waiting";
411 this.#notify(channel, "waiting");
412 }
413
414 // Schedule commit based on mode
415 this.#scheduleCommit(key, channel);
416
417 return promise;
418 }
419
420 #scheduleCommit(
421 key: string,
422 channel: DebouncedChannel<
423 Args,
424 Result,
425 Optimistic,
426 Config["optimisticHelpers"]
427 >,
428 ) {
429 const time = this.#options.time ?? 200;
430
431 if (this.#options.mode !== "throttle") {
432 // Debounce: reset timer on each call
433 if (channel.timer !== null) {
434 clearTimeout(channel.timer);
435 }
436 channel.timer = setTimeout(() => this.#commit(key, channel), time);
437 } else {
438 // Throttle: commit immediately if enough time passed, otherwise wait
439 // Use status to track if a commit is in progress
440 if (channel.timer === null && channel.status === "waiting") {
441 const elapsed = Date.now() - channel.lastCommitTime;
442 if (elapsed >= time) {
443 // Enough time has passed, commit immediately
444 this.#commit(key, channel);
445 } else {
446 // Wait for remaining time
447 channel.timer = setTimeout(
448 () => this.#commit(key, channel),
449 time - elapsed,
450 );
451 }
452 }
453 // If timer exists or commit is in progress, do nothing - will commit when ready
454 }
455 }
456
457 #commit(
458 key: string,
459 channel: DebouncedChannel<
460 Args,
461 Result,
462 Optimistic,
463 Config["optimisticHelpers"]
464 >,
465 ) {
466 // Clear timer
467 if (channel.timer !== null) {
468 clearTimeout(channel.timer);
469 channel.timer = null;
470 }
471
472 // Safety check
473 if (channel.firstArgs === null || channel.initial === null) {
474 this.#setIdle(key, channel);
475 return;
476 }
477
478 const firstArgs = channel.firstArgs;
479 const initial = channel.initial;
480 const pendingItems = [...channel.pending];
481 const rollbacks = [...channel.rollbacks];
482 const refetchCallbacks = [...channel.refetches];
483
484 // Get current snapshot
485 const current = this.#options.getValue({
486 ...this.#client.context,
487 args: firstArgs,
488 });
489
490 // Check if anything changed
491 if (this.#client.deepEquals(initial, current)) {
492 // No change - resolve all pending with a null result and reset
493 pendingItems.forEach(({ resolve }) => resolve(null as Result));
494 this.#resetDebouncedState(channel);
495 this.#setIdle(key, channel);
496 return;
497 }
498
499 // Set status to mutating
500 channel.status = "mutating";
501 this.#notify(channel, "mutating");
502
503 // Clear debounced state before async operation (but keep rollbacks/refetches for error case)
504 channel.initial = null;
505 channel.firstArgs = null;
506 channel.pending = [];
507 channel.rollbacks = [];
508 channel.refetches = [];
509
510 // Call commit
511 this.#options
512 .commit({
513 ...this.#client.context,
514 args: firstArgs,
515 initial,
516 current,
517 })
518 .then((result) => {
519 // Success - rollbacks are discarded (optimistic was correct)
520 // Resolve all pending promises
521 pendingItems.forEach(({ resolve }) => resolve(result));
522
523 // Report success globally if any of the pending items requested it
524 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
525 if (shouldReportSuccess) {
526 const message = this.#describeResult(
527 firstArgs,
528 initial,
529 current,
530 result,
531 );
532 if (message && this.#client.reportSuccess) {
533 this.#client.reportSuccess(message);
534 }
535 }
536
537 // Record commit time for throttle mode
538 channel.lastCommitTime = Date.now();
539
540 // Refetch
541 channel.status = "refetching";
542 this.#notify(channel, "refetching", result);
543 // Call refetch and all refetch callbacks in parallel
544 Promise.allSettled([
545 this.#options.refetch?.({
546 ...this.#client.context,
547 args: firstArgs,
548 }),
549 ...refetchCallbacks.map((cb) => cb()),
550 ]).then((results) => {
551 // Report any errors from refetch or callbacks
552 results.forEach((result) => {
553 if (result.status === "rejected") {
554 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
555 this.#client.reportError(message, result.reason);
556 }
557 });
558 }).finally(() => {
559 // Check if new calls came in during the commit
560 if (channel.pending.length > 0) {
561 // There are pending calls that need to be committed
562 channel.status = "waiting";
563 this.#notify(channel, "waiting");
564 this.#scheduleCommit(key, channel);
565 } else {
566 this.#setIdle(key, channel);
567 }
568 });
569 })
570 .catch((error) => {
571 // Error - call all rollbacks in reverse order
572 let next;
573 const rollbacksCopy = [...rollbacks];
574 while ((next = rollbacksCopy.pop())) next();
575
576 // Reject all pending promises
577 pendingItems.forEach(({ reject }) => reject(error));
578
579 // Notify listeners of the error
580 this.#notify(channel, "mutating", null, error);
581
582 // Refetch to restore correct state
583 channel.status = "refetching";
584 this.#notify(channel, "refetching", null, error);
585 // Call refetch and all refetch callbacks in parallel
586 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
587 (results) => {
588 // Report any errors from refetch or callbacks
589 results.forEach((result) => {
590 if (result.status === "rejected") {
591 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
592 this.#client.reportError(message, result.reason);
593 }
594 });
595 },
596 ).finally(() => {
597 // Check if new calls came in during the commit
598 if (channel.pending.length > 0) {
599 // There are pending calls that need to be committed
600 channel.status = "waiting";
601 this.#notify(channel, "waiting");
602 this.#scheduleCommit(key, channel);
603 } else {
604 this.#setIdle(key, channel);
605 }
606 });
607 });
608 }
609}
src/mod.ts+1-2
...@@ -1,11 +1,10 @@...@@ -1,11 +1,10 @@
1export type { MutationOptions, OptimisticContext } from "./blocking.ts";
2export {1export {
3 MutationClient,2 MutationClient,
4 type MutationClientConfig,3 type MutationClientConfig,
5 type MutationClientFromConfig,4 type MutationClientFromConfig,
6 type MutationClientOptions,5 type MutationClientOptions,
7} from "./client.ts";6} from "./client.ts";
8export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts";7export type { MutationOptions, OptimisticContext } from "./mutation.ts";
9export {8export {
10 createMutationButton,9 createMutationButton,
11 type MutationButtonComponent,10 type MutationButtonComponent,
src/mutation.ts created+662
...@@ -0,0 +1,662 @@
1import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
3import type { MutationClientConfig } from "./client.ts";
4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
5
6/**
7 * Argument to `defineBlocking`.
8 * @template Args - the parameters to the mutation
9 * @template Result - the result of the API call
10 * @template Config - global values and helpers from `MutationContext`
11 */
12export interface MutationOptions<
13 Args extends unknown[],
14 Result,
15 Config extends MutationClientConfig,
16> {
17 /**
18 * This function is only responsible for performing the underlying API call,
19 * syncronizing the optimistic state with reality. Throw on failure. A rest
20 * params type is used to allow type inference. Place this function first to
21 * ensure TypeScript correctly infers the argument type for the rest of the
22 * functions.
23 *
24 * In practice, optimistic context is never needed in this function, but it
25 * is provided as the `this` value if you truly desire it.
26 */
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
28 /**
29 * Used in error messages and debug tools.
30 * Phrase it considering the template `Failed to ${describe(...)}`
31 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);
33 /**
34 * Used in success messages.
35 * Phrase it as a complete success message: "Deleted Item"
36 */
37 describeResult:
38 | string
39 | ((context: Config["context"] & { args: Args; result: Result }) => string)
40 | null;
41 /**
42 * Specifying the optimistic strategy is required. To disable, pass an empty
43 * function with a comment to document why it isn't needed.
44 */
45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;
48 /**
49 * If the optimistic updator function is perfect, then this may be set to false.
50 * @default true
51 */
52 refetchOnSuccess?: boolean;
53 /**
54 * A key to associate related items. For example, returning a user ID. If
55 * specifying, then all mutations of the same key will evaluate in serial,
56 * but optimistic updates will apply instantly.
57 */
58 key?: (context: Config["context"] & { args: Args }) => string | string[];
59 /**
60 * Enable debouncing with "last call wins" behavior. When rapid calls arrive,
61 * the previous optimistic update is rolled back and the new one applied.
62 *
63 * All pending promises resolve with the final result.
64 */
65 debounceMs?: number;
66}
67
68export type OptimisticContext<
69 Args extends unknown[],
70 Result,
71 Config extends MutationClientConfig,
72> = Config["context"] & {
73 args: Args;
74 helpers: Config["optimisticHelpers"];
75 /** Add an event listener to roll back the update */
76 onRestore: (cb: () => void) => void;
77 /** Add an event listener to apply `Result` to the store. */
78 onSuccess: (cb: (result: Result) => void) => void;
79 /** Add an event listener to refetch data after mutation. */
80 onRefetch: (cb: () => Promise<void>) => void;
81};
82
83interface PendingDebouncedState<Args extends unknown[], Result> {
84 /** Arguments from the most recent call */
85 args: Args;
86 /** Number of rollbacks the most recent call added */
87 rollbackCount: number;
88 /** All pending promises from all superseded calls */
89 pending: Array<{
90 resolve: (result: Result) => void;
91 reject: (error: unknown) => void;
92 }>;
93 /** Success callbacks from the most recent call */
94 onSuccess: Array<(result: Result) => void>;
95}
96
97interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
98 listeners: Set<(update: MutationEvent<Result>) => void>;
99 status: "idle" | "waiting" | "mutating" | "refetching";
100 rollbacks: Array<() => void>;
101 refetches: Array<() => Promise<void>>;
102 queue: Array<Item<Args, Result>>;
103 // Shared optimistic helpers instance for the channel
104 helpers: OptimisticHelpers | null;
105 // Debounce state (only used if debounce option is set)
106 debounceTimer: ReturnType<typeof setTimeout> | null;
107 pendingDebounced: PendingDebouncedState<Args, Result> | null;
108}
109
110interface Item<Args extends unknown[], Result> {
111 args: Args;
112 rollbacks: number;
113 onSuccess: Array<(result: Result) => void>;
114 resolve: (result: Result) => void;
115 reject: (error: unknown) => void;
116}
117
118export class BlockingMutation<
119 Args extends unknown[],
120 Result,
121 Config extends MutationClientConfig,
122> implements Mutation<Args, Result> {
123 #options: MutationOptions<Args, Result, Config>;
124 #client: MutationClientFromConfig<Config>;
125 #channels: Map<
126 string,
127 Channel<Args, Result, Config["optimisticHelpers"]>
128 > = new Map();
129 client: MutationClientFromConfig<Config>;
130
131 constructor(
132 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
133 options: MutationOptions<Args, Result, Config>,
134 ) {
135 this.#options = options;
136 this.#client = client;
137 this.client = client;
138 }
139
140 key(args: Args) {
141 const k = this.#options.key?.({ ...this.#client.context, args })
142 ?? "shared";
143 return JSON.stringify(k);
144 }
145
146 #getOrPutChannel(key: string) {
147 let channel = this.#channels.get(key);
148 if (!channel) {
149 const rollbacks: Array<() => []> = [];
150 channel = {
151 listeners: new Set(),
152 status: "idle",
153 rollbacks,
154 refetches: [],
155 queue: [],
156 helpers: null,
157 debounceTimer: null,
158 pendingDebounced: null,
159 };
160 this.#channels.set(key, channel);
161 }
162 return channel;
163 }
164
165 subscribe(
166 key: string,
167 cb: (update: MutationEvent<Result>) => void,
168 ): () => void {
169 const channel = this.#getOrPutChannel(key);
170 channel.listeners.add(cb);
171 return () => channel?.listeners.delete(cb);
172 }
173
174 #notify(
175 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
176 status: MutationEvent<Result>["status"],
177 result: Result | null = null,
178 error: unknown = null,
179 ) {
180 const event: MutationEvent<Result> = { status, result, error };
181 channel.listeners.forEach((cb) => cb(event));
182 }
183
184 #setIdle(
185 key: string,
186 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
187 ) {
188 // Check if there are pending debounced calls waiting
189 if (channel.pendingDebounced !== null) {
190 // Stay in waiting state
191 channel.status = "waiting";
192 this.#notify(channel, "waiting", null, null);
193 } else {
194 // Normal idle transition
195 channel.status = "idle";
196 // Discard any unconsumed refetch callbacks
197 channel.refetches = [];
198 this.#notify(channel, "idle", null, null);
199 // Clean up the channel if there are no listeners
200 if (channel.listeners.size === 0) {
201 // Clear any pending timers before deleting the channel
202 if (channel.debounceTimer !== null) {
203 clearTimeout(channel.debounceTimer);
204 channel.debounceTimer = null;
205 }
206 this.#channels.delete(key);
207 }
208 }
209 }
210
211 describe(...args: Args): string {
212 const { describe } = this.#options;
213 return typeof describe === "function"
214 ? describe({ ...this.#client.context, args })
215 : describe;
216 }
217
218 describeResult(args: Args, result: Result): string | undefined {
219 const { describeResult } = this.#options;
220 if (describeResult === null) return undefined;
221 return typeof describeResult === "function"
222 ? describeResult({ ...this.#client.context, args, result })
223 : describeResult;
224 }
225
226 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
227 run(...args: Args) {
228 this.runWithOptions(...args, {});
229 }
230
231 /** Calls the mutation with custom handlers that can suppress global handlers. */
232 runWithOptions(...array: [...Args, RunOptions<Result>]): Promise<Result> {
233 if (!this.#client.enabled) {
234 throw new Error(
235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
236 );
237 }
238
239 const args = array.slice() as Args;
240 const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args
241 .pop() as RunOptions<Result>;
242 const suppressGlobalSuccess = onSuccess !== undefined;
243 const suppressGlobalError = onError !== undefined;
244
245 const promise = this.#runAsPromiseWithOptions(args, { onRestore });
246 promise.then((result) => {
247 // Call user handlers
248 onSuccess?.(result);
249 onSuccessDataOnly?.(result);
250 onSettled?.({ status: "success", result });
251
252 // Call global handler unless suppressed
253 if (!suppressGlobalSuccess) {
254 const message = this.describeResult(args, result);
255 if (message && this.#client.reportSuccess) {
256 this.#client.reportSuccess(message);
257 }
258 }
259 }).catch((error) => {
260 // Call user handlers
261 onError?.(error);
262 onSettled?.({ status: "error", error });
263
264 // Call global handler unless suppressed
265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
267 this.#client.reportError(message, error);
268 }
269 });
270
271 return promise;
272 }
273
274 /** Calls the mutation, treating the errors as promise rejection. */
275 runAsPromise(...args: Args): Promise<Result> {
276 return this.#runAsPromiseWithOptions(args, {});
277 }
278
279 #runAsPromiseWithOptions(
280 args: Args,
281 { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">,
282 ): Promise<Result> {
283 if (!this.#client.enabled) {
284 throw new Error(
285 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
286 );
287 }
288 const key = this.key(args);
289 const channel = this.#getOrPutChannel(key);
290
291 // Check if debouncing is enabled
292 if (this.#options.debounceMs !== undefined) {
293 return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true);
294 }
295
296 // Create shared optimistic helpers instance for the channel if it doesn't exist
297 if (channel.helpers === null) {
298 const onRefetch = (cb: () => Promise<void>) => {
299 channel.refetches.push(cb);
300 };
301
302 channel.helpers = this.#client.getOptimisticHelpers({
303 onRestore: (cb: () => void) => {
304 channel.rollbacks.push(cb);
305 },
306 onRefetch,
307 });
308 }
309
310 const onSuccess: Array<(result: Result) => void> = [];
311 let expired = false;
312 let rollbacks = 0;
313 const onRestore = (cb: () => void) => {
314 if (expired) {
315 throw new Error(
316 "Can only call onRestore from within the optimistic update function.",
317 );
318 }
319 channel.rollbacks.push(cb);
320 rollbacks += 1;
321 };
322
323 // Register user's onRestore callback if provided
324 if (userOnRestore) {
325 channel.rollbacks.push(userOnRestore);
326 rollbacks += 1;
327 }
328
329 try {
330 this.#options.optimistic({
331 args,
332 helpers: channel.helpers,
333 onRestore,
334 onSuccess(cb) {
335 if (expired) {
336 throw new Error(
337 "Can only call onSuccess from within the optimistic update function.",
338 );
339 }
340 onSuccess.push(cb);
341 },
342 onRefetch(cb) {
343 if (expired) {
344 throw new Error(
345 "Can only call onRefetch from within the optimistic update function.",
346 );
347 }
348 channel.refetches.push(cb);
349 },
350 });
351 } catch (error) {
352 expired = true;
353 let next;
354 while (
355 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
356 ) {
357 next();
358 }
359 return Promise.reject(error);
360 }
361 expired = true;
362
363 const { promise, resolve, reject } = Promise.withResolvers<Result>();
364 channel.queue.push({
365 args,
366 rollbacks,
367 onSuccess,
368 resolve,
369 reject,
370 });
371
372 if (channel.status === "idle") {
373 this.#executeNext(key, channel);
374 }
375
376 return promise;
377 }
378
379 #executeNext(
380 key: string,
381 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
382 ) {
383 const item = channel.queue.shift();
384 if (!item) {
385 this.#setIdle(key, channel);
386 return;
387 }
388
389 const { args, onSuccess, resolve, reject } = item;
390 channel.status = "mutating";
391 this.#notify(channel, "mutating");
392
393 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
394 // remove rollbacks and apply optimistic success handlers
395 channel.rollbacks.splice(0, item.rollbacks);
396 onSuccess.forEach((cb) => cb(result));
397
398 if (this.#options.refetchOnSuccess !== false) {
399 channel.status = "refetching";
400 this.#notify(channel, "refetching", result);
401 // Call refetch and all refetch callbacks in parallel
402 const refetchCallbacks = channel.refetches.splice(0);
403 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
404 (results) => {
405 // Report any errors from refetch or callbacks
406 results.forEach((result) => {
407 if (result.status === "rejected") {
408 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
409 this.#client.reportError(message, result.reason);
410 }
411 });
412 },
413 ).finally(() => {
414 this.#executeNext(key, channel);
415 });
416 } else {
417 // Discard refetch callbacks if refetchOnSuccess is false
418 channel.refetches = [];
419 this.#executeNext(key, channel);
420 }
421 resolve(result);
422 }, (error) => {
423 // if an error happens, then every rollback is called in reverse order
424 let next;
425 while (next = channel.rollbacks.pop()) next();
426
427 // Cancel all remaining items in the channel
428 const remainingItems = channel.queue.splice(0);
429 remainingItems.forEach((queuedItem) => {
430 queuedItem.reject(error);
431 });
432
433 // Notify listeners of the error
434 this.#notify(channel, "mutating", null, error);
435
436 // Refetch to restore correct state
437 channel.status = "refetching";
438 this.#notify(channel, "refetching", null, error);
439 // Call refetch and all refetch callbacks in parallel
440 const refetchCallbacks = channel.refetches.splice(0);
441 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => {
442 // Report any errors from refetch or callbacks
443 results.forEach((result) => {
444 if (result.status === "rejected") {
445 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
446 this.#client.reportError(message, result.reason);
447 }
448 });
449 }).finally(() => {
450 this.#setIdle(key, channel);
451 });
452
453 reject(error);
454 });
455 }
456
457 #runDebouncedAndReturn(
458 args: Args,
459 key: string,
460 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
461 userOnRestore?: () => void,
462 fromRunWithOptions = false,
463 ): Promise<Result> {
464 // If there's a pending debounced call, roll it back
465 if (channel.pendingDebounced) {
466 this.#rollbackPendingDebounced(channel);
467 }
468
469 // Create shared helpers if needed (same as current implementation)
470 if (channel.helpers === null) {
471 const onRefetch = (cb: () => Promise<void>) => {
472 channel.refetches.push(cb);
473 };
474 channel.helpers = this.#client.getOptimisticHelpers({
475 onRestore: (cb: () => void) => {
476 channel.rollbacks.push(cb);
477 },
478 onRefetch,
479 });
480 }
481
482 // Apply optimistic update (same logic as current runAndReturn)
483 const onSuccess: Array<(result: Result) => void> = [];
484 let expired = false;
485 let rollbacks = 0;
486 const onRestore = (cb: () => void) => {
487 if (expired) {
488 throw new Error(
489 "Can only call onRestore from within the optimistic update function.",
490 );
491 }
492 channel.rollbacks.push(cb);
493 rollbacks += 1;
494 };
495
496 // Register user's onRestore callback if provided
497 if (userOnRestore) {
498 channel.rollbacks.push(userOnRestore);
499 rollbacks += 1;
500 }
501
502 try {
503 this.#options.optimistic({
504 args,
505 helpers: channel.helpers,
506 onRestore,
507 onSuccess(cb) {
508 if (expired) {
509 throw new Error(
510 "Can only call onSuccess from within the optimistic update function.",
511 );
512 }
513 onSuccess.push(cb);
514 },
515 onRefetch(cb) {
516 if (expired) {
517 throw new Error(
518 "Can only call onRefetch from within the optimistic update function.",
519 );
520 }
521 channel.refetches.push(cb);
522 },
523 });
524 } catch (error) {
525 expired = true;
526 // Roll back the rollbacks we just added
527 let next;
528 while (
529 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
530 ) {
531 next();
532 }
533 return Promise.reject(error);
534 }
535 expired = true;
536
537 // Create promise for this call
538 const { promise, resolve, reject } = Promise.withResolvers<Result>();
539
540 // Store or update pending debounced state
541 if (channel.pendingDebounced === null) {
542 // First debounced call
543 channel.pendingDebounced = {
544 args,
545 rollbackCount: rollbacks,
546 pending: [{ resolve, reject }],
547 onSuccess,
548 };
549
550 // Set status to waiting
551 channel.status = "waiting";
552 this.#notify(channel, "waiting");
553 } else {
554 // Subsequent debounced call - update state
555 channel.pendingDebounced.args = args;
556 channel.pendingDebounced.rollbackCount = rollbacks;
557 channel.pendingDebounced.pending.push({ resolve, reject });
558 channel.pendingDebounced.onSuccess = onSuccess;
559 // Status stays "waiting"
560 }
561
562 // Clear existing timer
563 if (channel.debounceTimer !== null) {
564 clearTimeout(channel.debounceTimer);
565 }
566
567 // Start new timer
568 channel.debounceTimer = setTimeout(() => {
569 this.#enqueueDebouncedCall(key, channel);
570 }, this.#options.debounceMs);
571
572 return promise;
573 }
574
575 #rollbackPendingDebounced(
576 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
577 ) {
578 if (!channel.pendingDebounced) return;
579
580 const { rollbackCount } = channel.pendingDebounced;
581
582 // Roll back this call's optimistic updates (in reverse order)
583 // Remove from the end of the rollbacks array
584 for (let i = 0; i < rollbackCount; i++) {
585 const rollback = channel.rollbacks.pop();
586 if (rollback) rollback();
587 }
588
589 // Note: We do NOT reject the promises here
590 // They will all resolve when the final call completes
591 }
592
593 #enqueueDebouncedCall(
594 key: string,
595 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
596 ) {
597 // Clear timer
598 channel.debounceTimer = null;
599
600 // Safety check
601 if (!channel.pendingDebounced) {
602 this.#setIdle(key, channel);
603 return;
604 }
605
606 const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced;
607 channel.pendingDebounced = null;
608
609 // Check if there are any listeners at time of enqueue
610 const hasListeners = channel.listeners.size > 0;
611
612 // Create wrapper resolve/reject that resolves ALL pending promises
613 const {
614 promise: wrapperPromise,
615 resolve: wrapperResolve,
616 reject: wrapperReject,
617 } = Promise.withResolvers<Result>();
618
619 // Resolve/reject pending promises and add global handler logic for execution-time checks
620 wrapperPromise.then(
621 (result) => {
622 // Resolve all pending promises
623 pending.forEach((p) => p.resolve(result));
624
625 // Check if there are any listeners at execution time
626 const hasListeners = channel.listeners.size > 0;
627 if (!hasListeners) {
628 const message = this.describeResult(args, result);
629 if (message && this.#client.reportSuccess) {
630 this.#client.reportSuccess(message);
631 }
632 }
633 },
634 (error) => {
635 // Reject all pending promises
636 pending.forEach((p) => p.reject(error));
637
638 // Check if there are any listeners at execution time
639 const hasListeners = channel.listeners.size > 0;
640 if (!hasListeners) {
641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
642 this.#client.reportError(message, error);
643 }
644 },
645 );
646
647 // Add to queue (same structure as regular blocking mutation)
648 channel.queue.push({
649 args,
650 rollbacks: rollbackCount,
651 onSuccess,
652 resolve: wrapperResolve,
653 reject: wrapperReject,
654 });
655
656 // If queue was idle/waiting, start execution
657 if (channel.status === "idle" || channel.status === "waiting") {
658 this.#executeNext(key, channel);
659 }
660 // Otherwise, it will execute when the current item finishes
661 }
662}
test/blocking-debounce-edge-cases.test.ts deleted-630
...@@ -1,630 +0,0 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
3import { MutationClient } from "../src/client.ts";
4import type { MutationEvent } from "../src/types.ts";
5
6// Helper to create a test mutation client
7function createTestClient() {
8 const errors: Array<{ message: string; error: unknown }> = [];
9 const successes: string[] = [];
10 const client = new MutationClient({
11 context: { userId: "test-user" },
12 getOptimisticHelpers({ onRestore }) {
13 return {
14 setValue(key: string, value: string) {
15 testStore.set(key, value);
16 onRestore(() => testStore.delete(key));
17 },
18 };
19 },
20 reportError(message, error) {
21 errors.push({ message, error });
22 },
23 reportSuccess(message) {
24 successes.push(message);
25 },
26 });
27
28 return { client, errors, successes };
29}
30
31const testStore = new Map<string, string>();
32
33// Helper to track mutation events
34function createEventTracker<Result>() {
35 const events: Array<MutationEvent<Result>> = [];
36 const callback = (event: MutationEvent<Result>) => {
37 events.push(event);
38 };
39 return { events, callback };
40}
41
42// Helper to wait for async operations
43function delay(ms: number) {
44 return new Promise((resolve) => setTimeout(resolve, ms));
45}
46
47test("BlockingMutation - debounce: channel stays in waiting state with pending debounced", async () => {
48 const { client } = createTestClient();
49 const { events, callback } = createEventTracker<string>();
50
51 const mutation = client.define({
52 async mutate(value: string) {
53 await delay(20);
54 return `result-${value}`;
55 },
56 describe: "test mutation",
57 optimistic() {},
58 debounceMs: 100,
59 });
60
61 const key = mutation.key(["test"]);
62 mutation.subscribe(key, callback);
63
64 // Fire first debounced call
65 mutation.run("test1");
66
67 // Should be in waiting state
68 await delay(10);
69 assertEquals(events[events.length - 1].status, "waiting");
70
71 // Fire another call while still debouncing
72 mutation.run("test2");
73
74 // Should still be in waiting state
75 await delay(10);
76 assertEquals(events[events.length - 1].status, "waiting");
77
78 // Wait for debounce to complete
79 await delay(150);
80 assertEquals(events[events.length - 1].status, "idle");
81});
82
83test("BlockingMutation - debounce: mutation still executes after all listeners unsubscribe during waiting", async () => {
84 const { client, successes, errors } = createTestClient();
85 let mutateCallCount = 0;
86
87 const mutation = client.define({
88 async mutate(value: string) {
89 mutateCallCount++;
90 await delay(10);
91 return `result-${value}`;
92 },
93 describe: "test mutation",
94 describeResult: ({ args: [value] }) => `Successfully processed ${value}`,
95 optimistic() {},
96 debounceMs: 100,
97 });
98
99 const key = mutation.key(["test"]);
100 const unsubscribe = mutation.subscribe(key, () => {});
101
102 // Fire a debounced call
103 const promise = mutation.runAsPromise("test");
104
105 await delay(20);
106
107 // Unsubscribe while timer is pending
108 unsubscribe();
109
110 // Wait for debounce timer to fire and mutation to complete
111 await delay(150);
112
113 // Promise should resolve normally since mutation still executes
114 const result = await promise;
115 assertEquals(result, "result-test");
116
117 // Mutation should have been called despite no listeners
118 assertEquals(mutateCallCount, 1);
119
120 // Global success handler should be called since no local listeners
121 assertEquals(successes.length, 1);
122 assertEquals(successes[0], "Successfully processed test");
123});
124
125test("BlockingMutation - debounce: reportSuccess called when mutation succeeds", async () => {
126 const { client, successes } = createTestClient();
127
128 const mutation = client.define({
129 async mutate(value: string) {
130 await delay(10);
131 return `result-${value}`;
132 },
133 describe: "test mutation",
134 describeResult: ({ args: [value] }) => `Successfully processed ${value}`,
135 optimistic() {},
136 debounceMs: 50,
137 });
138
139 // Call run() (not runWithOptions with onSuccess)
140 mutation.run("test");
141
142 // Wait for debounce and execution
143 await delay(100);
144
145 // Global success handler should be called
146 assertEquals(successes.length, 1);
147 assertEquals(successes[0], "Successfully processed test");
148});
149
150test("BlockingMutation - debounce: runWithOptions suppresses global success handler", async () => {
151 const { client, successes } = createTestClient();
152
153 const mutation = client.define({
154 async mutate(value: string) {
155 await delay(10);
156 return `result-${value}`;
157 },
158 describe: "test mutation",
159 describeResult: ({ args: [value] }) => `Successfully processed ${value}`,
160 optimistic() {},
161 debounceMs: 50,
162 });
163
164 // Call with local onSuccess handler
165 mutation.runWithOptions("test", {
166 onSuccess: () => {},
167 });
168
169 // Wait for debounce and execution
170 await delay(100);
171
172 // Global success handler should NOT be called
173 assertEquals(successes.length, 0);
174});
175
176test("BlockingMutation - debounce: onRefetch callback in debounced path", async () => {
177 const { client } = createTestClient();
178 let refetchCalled = false;
179
180 const mutation = client.define({
181 async mutate(value: string) {
182 await delay(10);
183 return `result-${value}`;
184 },
185 describe: "test mutation",
186 optimistic({ onRefetch }) {
187 onRefetch(async () => {
188 refetchCalled = true;
189 await delay(5);
190 });
191 },
192 debounceMs: 50,
193 });
194
195 await mutation.runAsPromise("test");
196
197 // Wait for refetch to complete
198 await delay(30);
199
200 assertEquals(refetchCalled, true);
201});
202
203test("BlockingMutation - debounce: onRestore user callback executes", async () => {
204 const { client } = createTestClient();
205 testStore.clear();
206 let userRestoreCalled = false;
207
208 const mutation = client.define({
209 async mutate(value: string) {
210 await delay(10);
211 return `result-${value}`;
212 },
213 describe: "test mutation",
214 optimistic({ helpers }, value: string) {
215 helpers.setValue("key", value);
216 },
217 debounceMs: 50,
218 });
219
220 // Call with user onRestore - just verify the code path is covered
221 mutation.runWithOptions("test", {
222 onRestore: () => {
223 userRestoreCalled = true;
224 },
225 });
226
227 // Wait for completion - onRestore is called on rollback OR on error
228 // This test just ensures the code path with onRestore is executed
229 await delay(100);
230
231 // The callback may or may not be called depending on internal flow
232 // The important thing is the code path is covered
233});
234
235test("BlockingMutation - debounce: calling onSuccess outside optimistic throws", async () => {
236 const { client } = createTestClient();
237
238 const mutation = client.define({
239 async mutate(value: string) {
240 return value;
241 },
242 describe: "test mutation",
243 optimistic({ onSuccess }) {
244 // This is fine
245 onSuccess(() => {});
246
247 // But calling after optimistic completes should throw
248 setTimeout(() => {
249 try {
250 onSuccess(() => {});
251 } catch (e) {
252 // Expected error
253 }
254 }, 10);
255 },
256 debounceMs: 50,
257 });
258
259 await mutation.runAsPromise("test");
260 await delay(100);
261});
262
263test("BlockingMutation - debounce: calling onRefetch outside optimistic throws", async () => {
264 const { client } = createTestClient();
265
266 const mutation = client.define({
267 async mutate(value: string) {
268 return value;
269 },
270 describe: "test mutation",
271 optimistic({ onRefetch }) {
272 // This is fine
273 onRefetch(async () => {});
274
275 // But calling after optimistic completes should throw
276 setTimeout(() => {
277 try {
278 onRefetch(async () => {});
279 } catch (e) {
280 // Expected error
281 }
282 }, 10);
283 },
284 debounceMs: 50,
285 });
286
287 await mutation.runAsPromise("test");
288 await delay(100);
289});
290
291test("BlockingMutation - debounce: calling onRestore outside optimistic throws", async () => {
292 const { client } = createTestClient();
293
294 const mutation = client.define({
295 async mutate(value: string) {
296 return value;
297 },
298 describe: "test mutation",
299 optimistic({ onRestore }) {
300 // This is fine
301 onRestore(() => {});
302
303 // But calling after optimistic completes should throw
304 setTimeout(() => {
305 try {
306 onRestore(() => {});
307 } catch (e) {
308 // Expected error
309 }
310 }, 10);
311 },
312 debounceMs: 50,
313 });
314
315 await mutation.runAsPromise("test");
316 await delay(100);
317});
318
319test("BlockingMutation - debounce: error in optimistic path coverage", async () => {
320 const { client } = createTestClient();
321 testStore.clear();
322
323 const mutation = client.define({
324 async mutate(value: string) {
325 return value;
326 },
327 describe: "test mutation",
328 optimistic({ helpers, onRestore }, value: string) {
329 onRestore(() => {
330 testStore.delete("key1");
331 });
332
333 helpers.setValue("key1", value);
334
335 if (value === "error") {
336 throw new Error("Optimistic error");
337 }
338 },
339 debounceMs: 50,
340 });
341
342 // Call that throws during optimistic - this covers the error path
343 try {
344 mutation.runAsPromise("error");
345 await delay(10);
346 } catch (error) {
347 // Error expected
348 }
349
350 // Just verify the error path was covered
351 await delay(50);
352});
353
354test("BlockingMutation - debounce: rollback code path coverage", async () => {
355 const { client } = createTestClient();
356 testStore.clear();
357
358 const mutation = client.define({
359 async mutate(value: string) {
360 await delay(20);
361 return value;
362 },
363 describe: "test mutation",
364 optimistic({ helpers, onRestore }, value: string) {
365 onRestore(() => {
366 testStore.delete(`key-${value}`);
367 });
368 helpers.setValue(`key-${value}`, value);
369 },
370 debounceMs: 50,
371 });
372
373 // Fire rapid calls to trigger rollback paths
374 mutation.run("first");
375 await delay(10);
376 mutation.run("second");
377 await delay(10);
378 mutation.run("third");
379
380 // Wait for debounce and execution
381 await delay(150);
382
383 // Test just ensures rollback code paths are covered
384});
385
386test("BlockingMutation - debounce: enqueueDebouncedCall safety check", async () => {
387 const { client } = createTestClient();
388 const { events, callback } = createEventTracker<string>();
389
390 const mutation = client.define({
391 async mutate(value: string) {
392 await delay(10);
393 return value;
394 },
395 describe: "test mutation",
396 optimistic() {},
397 debounceMs: 50,
398 });
399
400 const key = mutation.key(["test"]);
401 mutation.subscribe(key, callback);
402
403 // Fire a debounced call
404 mutation.run("test");
405
406 await delay(20);
407
408 // Manually clear pendingDebounced to trigger safety check
409 // This simulates a race condition or edge case
410 // Note: This is a bit of a hack to test internal state, but it's the only way
411 // to trigger the safety check at line 608-610
412
413 // Wait for timer to fire
414 await delay(50);
415
416 // Should have completed normally despite the edge case
417 await delay(50);
418 assertEquals(events[events.length - 1].status, "idle");
419});
420
421test("BlockingMutation - debounce: channel transitions from waiting to idle after completion", async () => {
422 const { client } = createTestClient();
423 const { events, callback } = createEventTracker<string>();
424
425 const mutation = client.define({
426 async mutate(value: string) {
427 await delay(20);
428 return `result-${value}`;
429 },
430 describe: "test mutation",
431 optimistic() {},
432 debounceMs: 50,
433 });
434
435 const key = mutation.key(["test"]);
436 mutation.subscribe(key, callback);
437
438 // Fire debounced call
439 mutation.run("test");
440
441 // Should start in waiting
442 await delay(10);
443 const waitingEvent = events.find((e) => e.status === "waiting");
444 assertEquals(waitingEvent !== undefined, true);
445
446 // Wait for completion
447 await delay(150);
448
449 // Should end in idle
450 const finalEvent = events[events.length - 1];
451 assertEquals(finalEvent.status, "idle");
452 // Result will be in the previous event (success/mutating)
453 const hasSuccessResult = events.some((e) => e.result === "result-test");
454 assertEquals(hasSuccessResult, true);
455});
456
457test("BlockingMutation - debounce: onRefetch code path coverage", async () => {
458 const { client } = createTestClient();
459
460 const mutation = client.define({
461 async mutate(value: string) {
462 await delay(10);
463 return value;
464 },
465 describe: "test mutation",
466 optimistic({ onRefetch }, value: string) {
467 // This covers the onRefetch code path in debounced mutations
468 onRefetch(async () => {
469 await delay(5);
470 });
471 },
472 debounceMs: 50,
473 });
474
475 // Fire calls to cover onRefetch path
476 mutation.run("first");
477 await delay(10);
478 mutation.run("second");
479
480 // Wait for completion
481 await delay(150);
482});
483
484test("BlockingMutation - debounce: onSuccessDataOnly callback is called", async () => {
485 const { client } = createTestClient();
486 let successDataOnlyResult: string | undefined;
487
488 const mutation = client.define({
489 async mutate(value: string) {
490 await delay(10);
491 return `result-${value}`;
492 },
493 describe: "test mutation",
494 optimistic() {},
495 debounceMs: 50,
496 });
497
498 mutation.runWithOptions("test", {
499 onSuccessDataOnly: (result) => {
500 successDataOnlyResult = result;
501 },
502 });
503
504 await delay(100);
505
506 assertEquals(successDataOnlyResult, "result-test");
507});
508
509test("BlockingMutation - debounce: multiple pending promises all resolve to same result", async () => {
510 const { client } = createTestClient();
511
512 const mutation = client.define({
513 async mutate(value: string) {
514 await delay(20);
515 return `result-${value}`;
516 },
517 describe: "test mutation",
518 optimistic() {},
519 debounceMs: 50,
520 });
521
522 // Fire rapid calls and collect promises
523 const promise1 = mutation.runAsPromise("first");
524 await delay(10);
525 const promise2 = mutation.runAsPromise("second");
526 await delay(10);
527 const promise3 = mutation.runAsPromise("third");
528
529 // All promises should resolve
530 const [result1, result2, result3] = await Promise.all([
531 promise1,
532 promise2,
533 promise3,
534 ]);
535
536 // All should get the result of the last call
537 assertEquals(result1, "result-third");
538 assertEquals(result2, "result-third");
539 assertEquals(result3, "result-third");
540});
541
542test("BlockingMutation - debounce: error in mutation rejects all pending promises", async () => {
543 const { client } = createTestClient();
544
545 const mutation = client.define({
546 async mutate(value: string) {
547 await delay(10);
548 if (value === "error") {
549 throw new Error("Mutation failed");
550 }
551 return value;
552 },
553 describe: "test mutation",
554 optimistic() {},
555 debounceMs: 50,
556 });
557
558 // Fire rapid calls that will eventually error
559 const promise1 = mutation.runAsPromise("first");
560 await delay(10);
561 const promise2 = mutation.runAsPromise("second");
562 await delay(10);
563 const promise3 = mutation.runAsPromise("error");
564
565 // All promises should reject with the same error
566 await assertRejects(() => promise1, Error, "Mutation failed");
567 await assertRejects(() => promise2, Error, "Mutation failed");
568 await assertRejects(() => promise3, Error, "Mutation failed");
569});
570
571test("BlockingMutation - debounce: different keys handled independently", async () => {
572 const { client } = createTestClient();
573 testStore.clear();
574 const completions: string[] = [];
575
576 const mutation = client.define({
577 async mutate(id: string, value: string) {
578 await delay(20);
579 completions.push(`${id}:${value}`);
580 return `${id}:${value}`;
581 },
582 key: ({ args: [id] }) => id,
583 describe: "test mutation",
584 optimistic({ helpers }, id: string, value: string) {
585 helpers.setValue(`key-${id}`, value);
586 },
587 debounceMs: 50,
588 });
589
590 // Fire calls with different keys
591 mutation.run("key1", "value1");
592 mutation.run("key2", "value2");
593
594 // Wait for debounce and execution
595 await delay(200);
596
597 // Both mutations should have completed
598 assertEquals(completions.length, 2);
599});
600
601test("BlockingMutation - debounce: enabled=false throws error in run()", async () => {
602 const client = new MutationClient({
603 enabled: false,
604 context: {},
605 getOptimisticHelpers() {
606 return {};
607 },
608 reportError() {},
609 });
610
611 const mutation = client.define({
612 async mutate(value: string) {
613 return value;
614 },
615 describe: "test mutation",
616 optimistic() {},
617 debounceMs: 50,
618 });
619
620 // Should throw when trying to run with enabled=false
621 try {
622 mutation.run("test");
623 throw new Error("Should have thrown");
624 } catch (error) {
625 assertEquals(
626 (error as Error).message.includes("enabled: false"),
627 true,
628 );
629 }
630});
test/blocking.test.ts deleted-1418
...@@ -1,1418 +0,0 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
3import { MutationClient } from "../src/client.ts";
4import type { MutationEvent } from "../src/types.ts";
5
6// Helper to create a test mutation client
7function createTestClient() {
8 const errors: Array<{ message: string; error: unknown }> = [];
9 const successes: string[] = [];
10 const client = new MutationClient({
11 context: { userId: "test-user" },
12 getOptimisticHelpers({ onRestore }) {
13 return {
14 setValue(key: string, value: string) {
15 testStore.set(key, value);
16 onRestore(() => testStore.delete(key));
17 },
18 };
19 },
20 reportError(message, error) {
21 errors.push({ message, error });
22 },
23 reportSuccess(message) {
24 successes.push(message);
25 },
26 });
27
28 return { client, errors, successes };
29}
30
31const testStore = new Map<string, string>();
32
33// Helper to track mutation events
34function createEventTracker<Result>() {
35 const events: Array<MutationEvent<Result>> = [];
36 const callback = (event: MutationEvent<Result>) => {
37 events.push(event);
38 };
39 return { events, callback };
40}
41
42// Helper to wait for async operations
43function delay(ms: number) {
44 return new Promise((resolve) => setTimeout(resolve, ms));
45}
46
47test("BlockingMutation - basic mutation success", async () => {
48 const { client } = createTestClient();
49 let mutateCallCount = 0;
50 let refetchCallCount = 0;
51
52 const mutation = client.define({
53 async mutate(value: string) {
54 mutateCallCount++;
55 await delay(10);
56 return `result-${value}`;
57 },
58 describe: "test mutation",
59 describeResult: "Success",
60 optimistic({ onRefetch }) {
61 onRefetch(async () => {
62 refetchCallCount++;
63 await delay(5);
64 });
65 },
66 });
67
68 const result = await mutation.runAsPromise("test");
69 // Wait for refetch to complete
70 await delay(20);
71
72 assertEquals(result, "result-test");
73 assertEquals(mutateCallCount, 1);
74 assertEquals(refetchCallCount, 1);
75});
76
77test("BlockingMutation - run() catches errors", async () => {
78 const { client, errors } = createTestClient();
79
80 const mutation = client.define({
81 async mutate(_value: string) {
82 throw new Error("mutation failed");
83 },
84 describe: "failing mutation",
85 describeResult: "Success",
86 optimistic() {},
87 });
88
89 mutation.run("test");
90 await delay(50);
91
92 assertEquals(errors.length, 1);
93 assertEquals((errors[0].error as Error).message, "mutation failed");
94});
95
96test("BlockingMutation - runAndReturn() rejects on error", async () => {
97 const { client } = createTestClient();
98
99 const mutation = client.define({
100 async mutate(_value: string) {
101 throw new Error("mutation failed");
102 },
103 describe: "failing mutation",
104 describeResult: "Success",
105 optimistic() {},
106 });
107
108 await assertRejects(
109 () => mutation.runAsPromise("test"),
110 Error,
111 "mutation failed",
112 );
113});
114
115test("BlockingMutation - optimistic updates are applied immediately", async () => {
116 const { client } = createTestClient();
117 testStore.clear();
118
119 const mutation = client.define({
120 async mutate(_key: string, value: string) {
121 await delay(50);
122 return value;
123 },
124 describe: "set value",
125 describeResult: "Success",
126 optimistic({ args, helpers }) {
127 const [key, value] = args;
128 helpers.setValue(key, value);
129 },
130 });
131
132 const promise = mutation.runAsPromise("key1", "value1");
133
134 // Optimistic update should be applied synchronously
135 assertEquals(testStore.get("key1"), "value1");
136
137 // Wait for mutation to complete
138 await promise;
139 await delay(10);
140});
141
142test("BlockingMutation - rollback on error", async () => {
143 const { client } = createTestClient();
144 testStore.clear();
145
146 const mutation = client.define({
147 async mutate(_key: string, _value: string) {
148 await delay(10);
149 throw new Error("mutation failed");
150 },
151 describe: "failing mutation",
152 describeResult: "Success",
153 optimistic({ args, helpers }) {
154 const [key, value] = args;
155 helpers.setValue(key, value);
156 },
157 });
158
159 await assertRejects(() => mutation.runAsPromise("key1", "value1"));
160
161 // Optimistic update should be rolled back
162 assertEquals(testStore.has("key1"), false);
163});
164
165test("BlockingMutation - onSuccess callback is called", async () => {
166 const { client } = createTestClient();
167 const successResults: string[] = [];
168
169 const mutation = client.define({
170 async mutate(value: string) {
171 return `result-${value}`;
172 },
173 describe: "test mutation",
174 describeResult: "Success",
175 optimistic({ onSuccess }) {
176 onSuccess((result) => {
177 successResults.push(result);
178 });
179 },
180 });
181
182 await mutation.runAsPromise("test");
183
184 assertEquals(successResults, ["result-test"]);
185});
186
187test("BlockingMutation - mutations with same key execute serially", async () => {
188 const { client } = createTestClient();
189 const executionOrder: string[] = [];
190
191 const mutation = client.define({
192 async mutate(id: string) {
193 executionOrder.push(`start-${id}`);
194 await delay(20);
195 executionOrder.push(`end-${id}`);
196 return id;
197 },
198 describe: "test mutation",
199 describeResult: "Success",
200 optimistic() {},
201
202 refetchOnSuccess: false,
203 key() {
204 return "same-key";
205 },
206 });
207
208 // Start two mutations with the same key
209 const promise1 = mutation.runAsPromise("1");
210 const promise2 = mutation.runAsPromise("2");
211
212 await Promise.all([promise1, promise2]);
213 await delay(10);
214
215 // They should execute serially, not in parallel
216 assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]);
217});
218
219test("BlockingMutation - mutations with different keys execute in parallel", async () => {
220 const { client } = createTestClient();
221 const executionOrder: string[] = [];
222
223 const mutation = client.define({
224 async mutate(id: string) {
225 executionOrder.push(`start-${id}`);
226 await delay(20);
227 executionOrder.push(`end-${id}`);
228 return id;
229 },
230 describe: "test mutation",
231 describeResult: "Success",
232 optimistic() {},
233
234 key({ args }) {
235 const [id] = args;
236 return id;
237 },
238 });
239
240 // Start two mutations with different keys
241 const promise1 = mutation.runAsPromise("key1");
242 const promise2 = mutation.runAsPromise("key2");
243
244 await Promise.all([promise1, promise2]);
245
246 // They should start in parallel
247 assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]);
248});
249
250test("BlockingMutation - key() returns JSON stringified key", () => {
251 const { client } = createTestClient();
252
253 const mutation = client.define({
254 async mutate(id: string) {
255 return id;
256 },
257 describe: "test mutation",
258 describeResult: "Success",
259 optimistic() {},
260
261 key({ args }) {
262 const [id] = args;
263 return id;
264 },
265 });
266
267 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
268});
269
270test("BlockingMutation - key() defaults to 'shared' when no key function", () => {
271 const { client } = createTestClient();
272
273 const mutation = client.define({
274 async mutate(id: string) {
275 return id;
276 },
277 describe: "test mutation",
278 describeResult: "Success",
279 optimistic() {},
280 });
281
282 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
283});
284
285test("BlockingMutation - key() can return array", () => {
286 const { client } = createTestClient();
287
288 const mutation = client.define({
289 async mutate(_userId: string, _itemId: string) {
290 return "result";
291 },
292 describe: "test mutation",
293 describeResult: "Success",
294 optimistic() {},
295
296 key({ args }) {
297 const [userId, itemId] = args;
298 return [userId, itemId];
299 },
300 });
301
302 assertEquals(
303 mutation.key(["user1", "item1"]),
304 JSON.stringify(["user1", "item1"]),
305 );
306});
307
308test("BlockingMutation - describe() with string", () => {
309 const { client } = createTestClient();
310
311 const mutation = client.define({
312 async mutate(value: string) {
313 return value;
314 },
315 describe: "create item",
316 describeResult: "Success",
317 optimistic() {},
318 });
319
320 assertEquals(mutation.describe("test"), "create item");
321});
322
323test("BlockingMutation - describe() with function", () => {
324 const { client } = createTestClient();
325
326 const mutation = client.define({
327 async mutate(id: string) {
328 return id;
329 },
330 describe({ args }) {
331 const [id] = args;
332 return `delete item ${id}`;
333 },
334 describeResult: null,
335 optimistic() {},
336 });
337
338 assertEquals(mutation.describe("123"), "delete item 123");
339});
340
341test("BlockingMutation - describe() receives context", () => {
342 const { client } = createTestClient();
343
344 const mutation = client.define({
345 async mutate(id: string) {
346 return id;
347 },
348 describe({ userId, args }) {
349 const [id] = args;
350 return `user ${userId} editing item ${id}`;
351 },
352 optimistic() {},
353 describeResult: null,
354 });
355
356 assertEquals(
357 mutation.describe("123"),
358 "user test-user editing item 123",
359 );
360});
361
362test("BlockingMutation - subscribe() tracks mutation events", async () => {
363 const { client } = createTestClient();
364 const tracker = createEventTracker<string>();
365
366 const mutation = client.define({
367 async mutate(value: string) {
368 await delay(10);
369 return `result-${value}`;
370 },
371 describe: "test mutation",
372 describeResult: "Success",
373 optimistic({ onRefetch }) {
374 onRefetch(async () => {
375 await delay(5);
376 });
377 },
378 });
379
380 const key = mutation.key(["test"]);
381 mutation.subscribe(key, tracker.callback);
382
383 await mutation.runAsPromise("test");
384 // Wait for refetch to complete
385 await delay(20);
386
387 // Should have received status updates
388 assertEquals(tracker.events.length >= 2, true);
389 assertEquals(tracker.events.some((e) => e.status === "mutating"), true);
390 assertEquals(tracker.events.some((e) => e.status === "refetching"), true);
391});
392
393test("BlockingMutation - unsubscribe stops receiving events", async () => {
394 const { client } = createTestClient();
395 const tracker = createEventTracker<string>();
396
397 const mutation = client.define({
398 async mutate(value: string) {
399 await delay(10);
400 return value;
401 },
402 describe: "test mutation",
403 describeResult: "Success",
404 optimistic() {},
405
406 refetchOnSuccess: false,
407 });
408
409 const key = mutation.key(["test"]);
410 const unsubscribe = mutation.subscribe(key, tracker.callback);
411
412 unsubscribe();
413
414 await mutation.runAsPromise("test");
415 await delay(10);
416
417 // Should not have received any events
418 assertEquals(tracker.events.length, 0);
419});
420
421test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
422 const { client } = createTestClient();
423 let refetchCallCount = 0;
424
425 const mutation = client.define({
426 async mutate(_value: string) {
427 return _value;
428 },
429 describe: "test mutation",
430 describeResult: "Success",
431 optimistic({ onRefetch }) {
432 onRefetch(async () => {
433 refetchCallCount++;
434 });
435 },
436 refetchOnSuccess: false,
437 });
438
439 await mutation.runAsPromise("test");
440
441 assertEquals(refetchCallCount, 0);
442});
443
444test("BlockingMutation - refetch is called on error", async () => {
445 const { client } = createTestClient();
446 let refetchCallCount = 0;
447
448 const mutation = client.define({
449 async mutate(_value: string) {
450 throw new Error("mutation failed");
451 },
452 describe: "failing mutation",
453 describeResult: "Success",
454 optimistic({ onRefetch }) {
455 onRefetch(async () => {
456 refetchCallCount++;
457 });
458 },
459 });
460
461 await assertRejects(() => mutation.runAsPromise("test"));
462
463 assertEquals(refetchCallCount, 1);
464});
465
466test("BlockingMutation - queued mutations are cancelled on error", async () => {
467 const { client } = createTestClient();
468 const executionOrder: string[] = [];
469
470 const mutation = client.define({
471 async mutate(id: string) {
472 executionOrder.push(`start-${id}`);
473 await delay(10);
474 if (id === "1") {
475 throw new Error("first mutation failed");
476 }
477 executionOrder.push(`end-${id}`);
478 return id;
479 },
480 describe: "test mutation",
481 describeResult: "Success",
482 optimistic() {},
483
484 key() {
485 return "same-key";
486 },
487 });
488
489 const promise1 = mutation.runAsPromise("1");
490 const promise2 = mutation.runAsPromise("2");
491 const promise3 = mutation.runAsPromise("3");
492
493 await assertRejects(() => promise1, Error, "first mutation failed");
494 await assertRejects(() => promise2, Error, "first mutation failed");
495 await assertRejects(() => promise3, Error, "first mutation failed");
496
497 // Only the first mutation should start
498 assertEquals(executionOrder, ["start-1"]);
499});
500
501test("BlockingMutation - rollbacks are called in reverse order on error", async () => {
502 const { client } = createTestClient();
503 const rollbackOrder: number[] = [];
504
505 const mutation = client.define({
506 async mutate(_value: string) {
507 throw new Error("mutation failed");
508 },
509 describe: "failing mutation",
510 describeResult: "Success",
511 optimistic({ onRestore }) {
512 onRestore(() => rollbackOrder.push(1));
513 onRestore(() => rollbackOrder.push(2));
514 onRestore(() => rollbackOrder.push(3));
515 },
516 });
517
518 await assertRejects(() => mutation.runAsPromise("test"));
519
520 // Rollbacks should be called in reverse order
521 assertEquals(rollbackOrder, [3, 2, 1]);
522});
523
524test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => {
525 const { client } = createTestClient();
526 const rollbackOrder: string[] = [];
527
528 const mutation = client.define({
529 async mutate(id: string) {
530 await delay(10);
531 if (id === "fail") {
532 throw new Error("mutation failed");
533 }
534 return id;
535 },
536 describe: "test mutation",
537 describeResult: "Success",
538 optimistic({ args: [id], onRestore }) {
539 onRestore(() => rollbackOrder.push(`rollback-${id}`));
540 },
541
542 key() {
543 return "same-key";
544 },
545 });
546
547 // First mutation succeeds
548 await mutation.runAsPromise("success");
549
550 // Second mutation fails
551 await assertRejects(() => mutation.runAsPromise("fail"));
552
553 // Only the failed mutation's rollback should be called
554 // And all rollbacks from queued items
555 assertEquals(rollbackOrder, ["rollback-fail"]);
556});
557
558test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => {
559 const { client } = createTestClient();
560 let capturedOnRestore: ((cb: () => void) => void) | null = null;
561
562 const mutation = client.define({
563 async mutate(_value: string) {
564 return "result";
565 },
566 describe: "test mutation",
567 describeResult: "Success",
568 optimistic({ onRestore }) {
569 capturedOnRestore = onRestore;
570 },
571 });
572
573 await mutation.runAsPromise("test");
574
575 // Calling onRestore after the optimistic phase should throw
576 let error: Error | null = null;
577 try {
578 capturedOnRestore!(() => {});
579 } catch (e) {
580 error = e as Error;
581 }
582
583 assertEquals(
584 error?.message,
585 "Can only call onRestore from within the optimistic update function.",
586 );
587});
588
589test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => {
590 const { client } = createTestClient();
591 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
592
593 const mutation = client.define({
594 async mutate(_value: string) {
595 return "result";
596 },
597 describe: "test mutation",
598 describeResult: "Success",
599 optimistic({ onSuccess }) {
600 capturedOnSuccess = onSuccess;
601 },
602 });
603
604 await mutation.runAsPromise("test");
605
606 // Calling onSuccess after the optimistic phase should throw
607 let error: Error | null = null;
608 try {
609 capturedOnSuccess!(() => {});
610 } catch (e) {
611 error = e as Error;
612 }
613
614 assertEquals(
615 error?.message,
616 "Can only call onSuccess from within the optimistic update function.",
617 );
618});
619
620test("BlockingMutation - error during optimistic update is rejected immediately", async () => {
621 const { client } = createTestClient();
622
623 const mutation = client.define({
624 async mutate(_value: string) {
625 return "result";
626 },
627 describe: "test mutation",
628 describeResult: "Success",
629 optimistic() {
630 throw new Error("optimistic update failed");
631 },
632 });
633
634 await assertRejects(
635 () => mutation.runAsPromise("test"),
636 Error,
637 "optimistic update failed",
638 );
639});
640
641test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => {
642 const { client } = createTestClient();
643 const rollbackOrder: number[] = [];
644
645 const mutation = client.define({
646 async mutate(_value: string) {
647 return "result";
648 },
649 describe: "test mutation",
650 describeResult: "Success",
651 optimistic({ onRestore }) {
652 onRestore(() => rollbackOrder.push(1));
653 onRestore(() => rollbackOrder.push(2));
654 throw new Error("optimistic update failed");
655 },
656 });
657
658 await assertRejects(() => mutation.runAsPromise("test"));
659
660 // Rollbacks should be called even though optimistic update failed
661 // Note: during optimistic error, rollbacks are executed in the order they were added
662 assertEquals(rollbackOrder, [1, 2]);
663});
664
665test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => {
666 const { client, errors } = createTestClient();
667
668 const mutation = client.define({
669 async mutate(value: string) {
670 return value;
671 },
672 describe: "test mutation",
673 describeResult: "Success",
674 optimistic({ onRefetch }) {
675 onRefetch(async () => {
676 throw new Error("refetch failed");
677 });
678 },
679 });
680
681 // Mutation should still succeed
682 const result = await mutation.runAsPromise("test");
683 assertEquals(result, "test");
684
685 // But refetch error should be reported
686 await delay(20);
687 assertEquals(errors.length, 1);
688 assertEquals((errors[0].error as Error).message, "refetch failed");
689});
690
691test("BlockingMutation - optimistic function receives args and helpers", async () => {
692 const { client } = createTestClient();
693 let receivedArgs: unknown[] | undefined;
694 let receivedHelpers: unknown | undefined;
695
696 const mutation = client.define({
697 async mutate(_value: string) {
698 return "result";
699 },
700 describe: "test mutation",
701 describeResult: "Success",
702 optimistic({ args, helpers }) {
703 receivedArgs = args;
704 receivedHelpers = helpers;
705 },
706 });
707
708 await mutation.runAsPromise("test");
709
710 assertEquals(receivedArgs, ["test"]);
711 assertEquals(typeof receivedHelpers, "object");
712});
713
714test("BlockingMutation - notifies error on mutation failure", async () => {
715 const { client } = createTestClient();
716 const tracker = createEventTracker<string>();
717
718 const mutation = client.define({
719 async mutate(_value: string) {
720 await delay(10);
721 throw new Error("mutation failed");
722 },
723 describe: "failing mutation",
724 describeResult: "Success",
725 optimistic() {},
726 });
727
728 const key = mutation.key(["test"]);
729 mutation.subscribe(key, tracker.callback);
730
731 await assertRejects(() => mutation.runAsPromise("test"));
732
733 // Should have error event
734 const errorEvents = tracker.events.filter((e) => e.status === "mutating" && e.error);
735 assertEquals(errorEvents.length > 0, true);
736 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
737});
738
739test("BlockingMutation - multiple subscribers receive events", async () => {
740 const { client } = createTestClient();
741 const tracker1 = createEventTracker<string>();
742 const tracker2 = createEventTracker<string>();
743
744 const mutation = client.define({
745 async mutate(value: string) {
746 await delay(5);
747 return value;
748 },
749 describe: "test mutation",
750 describeResult: "Success",
751 optimistic() {},
752
753 refetchOnSuccess: false,
754 });
755
756 const key = mutation.key(["test"]);
757 mutation.subscribe(key, tracker1.callback);
758 mutation.subscribe(key, tracker2.callback);
759
760 await mutation.runAsPromise("test");
761 await delay(10);
762
763 // Both subscribers should receive events
764 assertEquals(tracker1.events.length, tracker2.events.length);
765 assertEquals(tracker1.events.length > 0, true);
766});
767
768test("BlockingMutation - onSuccess is called before mutation resolves", async () => {
769 const { client } = createTestClient();
770 const callOrder: string[] = [];
771
772 const mutation = client.define({
773 async mutate(value: string) {
774 return value;
775 },
776 describe: "test mutation",
777 describeResult: "Success",
778 optimistic({ onSuccess }) {
779 onSuccess(() => {
780 callOrder.push("onSuccess");
781 });
782 },
783
784 refetchOnSuccess: false,
785 });
786
787 const promise = mutation.runAsPromise("test");
788 promise.then(() => {
789 callOrder.push("then");
790 });
791
792 await promise;
793 await delay(5);
794
795 // onSuccess should be called before the promise resolves
796 assertEquals(callOrder, ["onSuccess", "then"]);
797});
798
799test("BlockingMutation - result is passed to notification on success", async () => {
800 const { client } = createTestClient();
801 const tracker = createEventTracker<string>();
802
803 const mutation = client.define({
804 async mutate(value: string) {
805 await delay(5);
806 return `result-${value}`;
807 },
808 describe: "test mutation",
809 describeResult: "Success",
810 optimistic() {},
811 });
812
813 const key = mutation.key(["test"]);
814 mutation.subscribe(key, tracker.callback);
815
816 await mutation.runAsPromise("test");
817 await delay(20);
818
819 // Should have refetching event with result
820 const refetchingEvents = tracker.events.filter((e) => e.status === "refetching");
821 assertEquals(refetchingEvents.length > 0, true);
822 assertEquals(refetchingEvents[0]?.result, "result-test");
823});
824
825test("BlockingMutation - channel is reused for same key", async () => {
826 const { client } = createTestClient();
827 const events: string[] = [];
828
829 const mutation = client.define({
830 async mutate(value: string) {
831 events.push(`mutate-${value}`);
832 return value;
833 },
834 describe: "test mutation",
835 describeResult: "Success",
836 optimistic() {},
837
838 refetchOnSuccess: false,
839 });
840
841 // First mutation
842 await mutation.runAsPromise("first");
843 await delay(5);
844
845 // Second mutation with same key
846 await mutation.runAsPromise("second");
847 await delay(5);
848
849 assertEquals(events, ["mutate-first", "mutate-second"]);
850});
851
852test("BlockingMutation - empty queue after all mutations complete", async () => {
853 const { client } = createTestClient();
854
855 const mutation = client.define({
856 async mutate(value: string) {
857 await delay(5);
858 return value;
859 },
860 describe: "test mutation",
861 describeResult: "Success",
862 optimistic() {},
863
864 refetchOnSuccess: false,
865 key() {
866 return "test-key";
867 },
868 });
869
870 // Run multiple mutations
871 await mutation.runAsPromise("1");
872 await mutation.runAsPromise("2");
873 await mutation.runAsPromise("3");
874 await delay(10);
875
876 // All mutations should have completed
877 // (We can't directly check the queue, but we can verify by running another mutation)
878 const start = Date.now();
879 await mutation.runAsPromise("4");
880 const duration = Date.now() - start;
881
882 // Should execute immediately, not be queued (< 10ms if not queued)
883 assertEquals(duration < 15, true);
884});
885
886test("BlockingMutation - multiple onSuccess callbacks are all called", async () => {
887 const { client } = createTestClient();
888 const results: string[] = [];
889
890 const mutation = client.define({
891 async mutate(value: string) {
892 return value;
893 },
894 describe: "test mutation",
895 describeResult: "Success",
896 optimistic({ onSuccess }) {
897 onSuccess((result) => results.push(`first-${result}`));
898 onSuccess((result) => results.push(`second-${result}`));
899 onSuccess((result) => results.push(`third-${result}`));
900 },
901
902 refetchOnSuccess: false,
903 });
904
905 await mutation.runAsPromise("test");
906
907 assertEquals(results, ["first-test", "second-test", "third-test"]);
908});
909
910test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
911 const { client } = createTestClient();
912 let refetchCalled = false;
913
914 const mutation = client.define({
915 async mutate(value: string) {
916 return value;
917 },
918 describe: "test mutation",
919 describeResult: "Success",
920 optimistic({ onRefetch }) {
921 onRefetch(async () => {
922 refetchCalled = true;
923 });
924 },
925 refetchOnSuccess: false,
926 });
927
928 await mutation.runAsPromise("test");
929 await delay(10);
930
931 // Refetch should not have been called
932 assertEquals(refetchCalled, false);
933});
934
935test("BlockingMutation - refetch error after mutation failure is reported", async () => {
936 const { client, errors } = createTestClient();
937
938 const mutation = client.define({
939 async mutate(_value: string) {
940 throw new Error("mutation failed");
941 },
942 describe: "failing mutation",
943 describeResult: "Success",
944 optimistic({ onRefetch }) {
945 onRefetch(async () => {
946 throw new Error("refetch also failed");
947 });
948 },
949 });
950
951 await assertRejects(
952 () => mutation.runAsPromise("test"),
953 Error,
954 "mutation failed",
955 );
956
957 // Wait for refetch to complete and error to be reported
958 await delay(20);
959
960 // Should have both the mutation error and refetch error reported
961 assertEquals(errors.length >= 1, true);
962 assertEquals(
963 (errors[errors.length - 1].error as Error).message,
964 "refetch also failed",
965 );
966});
967
968// ============================================================================
969// Debouncing Tests
970// ============================================================================
971
972test("BlockingMutation - debounce: basic debounced execution", async () => {
973 const { client } = createTestClient();
974 testStore.clear();
975 let mutateCallCount = 0;
976
977 const mutation = client.define({
978 async mutate(key: string, value: string) {
979 mutateCallCount++;
980 await delay(10);
981 return `result-${value}`;
982 },
983 describe: "debounced mutation",
984 describeResult: "Success",
985 optimistic({ args, helpers }) {
986 const [key, value] = args;
987 helpers.setValue(key, value);
988 },
989
990 debounceMs: 50,
991 });
992
993 const promise = mutation.runAsPromise("key1", "value1");
994
995 // Optimistic update should be applied immediately
996 assertEquals(testStore.get("key1"), "value1");
997
998 // Mutation should not have executed yet
999 assertEquals(mutateCallCount, 0);
1000
1001 // Wait for debounce to complete
1002 const result = await promise;
1003 assertEquals(result, "result-value1");
1004 assertEquals(mutateCallCount, 1);
1005});
1006
1007test("BlockingMutation - debounce: last call wins with multiple rapid calls", async () => {
1008 const { client } = createTestClient();
1009 testStore.clear();
1010 let mutateCallCount = 0;
1011 const mutateArgs: Array<[string, string]> = [];
1012
1013 const mutation = client.define({
1014 async mutate(key: string, value: string) {
1015 mutateCallCount++;
1016 mutateArgs.push([key, value]);
1017 await delay(10);
1018 return `result-${value}`;
1019 },
1020 describe: "debounced mutation",
1021 describeResult: "Success",
1022 optimistic({ args, helpers }) {
1023 const [key, value] = args;
1024 helpers.setValue(key, value);
1025 },
1026
1027 debounceMs: 50,
1028 });
1029
1030 // Make three rapid calls
1031 const promise1 = mutation.runAsPromise("key1", "a");
1032 const promise2 = mutation.runAsPromise("key1", "b");
1033 const promise3 = mutation.runAsPromise("key1", "c");
1034
1035 // Last optimistic update should be applied
1036 assertEquals(testStore.get("key1"), "c");
1037
1038 // Wait for debounce to complete
1039 const [result1, result2, result3] = await Promise.all([
1040 promise1,
1041 promise2,
1042 promise3,
1043 ]);
1044
1045 // All promises should resolve with the same result
1046 assertEquals(result1, "result-c");
1047 assertEquals(result2, "result-c");
1048 assertEquals(result3, "result-c");
1049
1050 // Only one mutation should have executed, with the last args
1051 assertEquals(mutateCallCount, 1);
1052 assertEquals(mutateArgs, [["key1", "c"]]);
1053});
1054
1055test("BlockingMutation - debounce: optimistic rollback and reapply", async () => {
1056 const { client } = createTestClient();
1057 testStore.clear();
1058
1059 const mutation = client.define({
1060 async mutate(key: string, value: string) {
1061 await delay(10);
1062 return `result-${value}`;
1063 },
1064 describe: "debounced mutation",
1065 describeResult: "Success",
1066 optimistic({ args, helpers }) {
1067 const [key, value] = args;
1068 helpers.setValue(key, value);
1069 // Add a second value to test multiple rollbacks
1070 helpers.setValue(`${key}-2`, `${value}-2`);
1071 },
1072
1073 debounceMs: 50,
1074 });
1075
1076 // First call sets two values
1077 mutation.runAsPromise("key1", "a");
1078 assertEquals(testStore.get("key1"), "a");
1079 assertEquals(testStore.get("key1-2"), "a-2");
1080
1081 // Second call should rollback first call's optimistic and apply its own
1082 const promise = mutation.runAsPromise("key1", "b");
1083 assertEquals(testStore.get("key1"), "b");
1084 assertEquals(testStore.get("key1-2"), "b-2");
1085
1086 // Wait for completion
1087 await promise;
1088 await delay(20);
1089
1090 // Final values should still be from the last call
1091 assertEquals(testStore.get("key1"), "b");
1092 assertEquals(testStore.get("key1-2"), "b-2");
1093});
1094
1095test("BlockingMutation - debounce: timer reset behavior", async () => {
1096 const { client } = createTestClient();
1097 let mutateCallCount = 0;
1098
1099 const mutation = client.define({
1100 async mutate(value: string) {
1101 mutateCallCount++;
1102 return `result-${value}`;
1103 },
1104 describe: "debounced mutation",
1105 describeResult: "Success",
1106 optimistic() {},
1107
1108 debounceMs: 100,
1109 });
1110
1111 // Call at t=0
1112 const promise1 = mutation.runAsPromise("first");
1113
1114 // Call at t=50 (should reset timer)
1115 await delay(50);
1116 const promise2 = mutation.runAsPromise("second");
1117
1118 // At t=100, mutation should NOT have executed yet
1119 await delay(50);
1120 assertEquals(mutateCallCount, 0);
1121
1122 // At t=150, mutation should execute
1123 await delay(50);
1124 await Promise.all([promise1, promise2]);
1125
1126 assertEquals(mutateCallCount, 1);
1127});
1128
1129test("BlockingMutation - debounce: integration with blocking queue", async () => {
1130 const { client } = createTestClient();
1131 const executionOrder: string[] = [];
1132
1133 const mutation = client.define({
1134 async mutate(id: string) {
1135 executionOrder.push(`start-${id}`);
1136 await delay(30);
1137 executionOrder.push(`end-${id}`);
1138 return `result-${id}`;
1139 },
1140 describe: "debounced mutation",
1141 describeResult: "Success",
1142 optimistic() {},
1143
1144 debounceMs: 30,
1145 key: () => "shared",
1146 });
1147
1148 // Start a debounced call that will enter queue first
1149 const promise1 = mutation.runAsPromise("first");
1150
1151 // While it's waiting in debounce, fire more debounced calls
1152 await delay(10);
1153 const promise2 = mutation.runAsPromise("second");
1154 const promise3 = mutation.runAsPromise("third");
1155
1156 // Wait for all to complete
1157 await Promise.all([promise1, promise2, promise3]);
1158
1159 // Only third should execute (last call wins)
1160 assertEquals(executionOrder, [
1161 "start-third",
1162 "end-third",
1163 ]);
1164});
1165
1166test("BlockingMutation - debounce: error during optimistic update", async () => {
1167 const { client } = createTestClient();
1168 testStore.clear();
1169
1170 const mutation = client.define({
1171 async mutate(_value: string) {
1172 return "result";
1173 },
1174 describe: "debounced mutation",
1175 describeResult: "Success",
1176 optimistic({ args, helpers }) {
1177 const [value] = args;
1178 if (value === "error") {
1179 throw new Error("optimistic error");
1180 }
1181 helpers.setValue("key", value);
1182 },
1183
1184 debounceMs: 50,
1185 });
1186
1187 // Call that throws during optimistic
1188 await assertRejects(
1189 () => mutation.runAsPromise("error"),
1190 Error,
1191 "optimistic error",
1192 );
1193
1194 // Store should be empty
1195 assertEquals(testStore.has("key"), false);
1196
1197 // Subsequent successful call should work
1198 const promise = mutation.runAsPromise("good");
1199 assertEquals(testStore.get("key"), "good");
1200 await promise;
1201});
1202
1203test("BlockingMutation - debounce: status transitions", async () => {
1204 const { client } = createTestClient();
1205 const { events, callback } = createEventTracker();
1206
1207 const mutation = client.define({
1208 async mutate(value: string) {
1209 await delay(20);
1210 return `result-${value}`;
1211 },
1212 describe: "debounced mutation",
1213 describeResult: "Success",
1214 optimistic({ onRefetch }) {
1215 onRefetch(async () => {
1216 await delay(10);
1217 });
1218 },
1219
1220 debounceMs: 50,
1221 });
1222
1223 const key = mutation.key(["test"]);
1224 const unsubscribe = mutation.subscribe(key, callback);
1225
1226 // First call should transition to waiting
1227 mutation.runAsPromise("test");
1228 await delay(10);
1229 assertEquals(events[events.length - 1].status, "waiting");
1230
1231 // Wait for debounce and mutation to complete
1232 await delay(80);
1233
1234 // Should have transitioned: waiting -> mutating -> refetching -> idle
1235 const statuses = events.map((e) => e.status);
1236 assertEquals(statuses, ["waiting", "mutating", "refetching", "idle"]);
1237
1238 unsubscribe();
1239});
1240
1241test("BlockingMutation - debounce: debounced call executes after queue error", async () => {
1242 const { client } = createTestClient();
1243 let callCount = 0;
1244
1245 const mutation = client.define({
1246 async mutate(id: string) {
1247 callCount++;
1248 if (id === "fail") {
1249 throw new Error("mutation failed");
1250 }
1251 await delay(20);
1252 return `result-${id}`;
1253 },
1254 describe: "debounced mutation",
1255 describeResult: "Success",
1256 optimistic({ onRefetch }) {
1257 onRefetch(async () => {
1258 await delay(10);
1259 });
1260 },
1261 debounceMs: 50,
1262 key: () => "shared",
1263 });
1264
1265 // Start a call that will fail (enters debounce)
1266 const promise1 = mutation.runAsPromise("fail");
1267
1268 // Immediately override with a successful call (last call wins)
1269 const promise2 = mutation.runAsPromise("success");
1270
1271 // Both promises should resolve with the same successful result
1272 // (because debouncing causes "last call wins")
1273 const result1 = await promise1;
1274 const result2 = await promise2;
1275
1276 assertEquals(result1, "result-success");
1277 assertEquals(result2, "result-success");
1278 assertEquals(callCount, 1); // Only one call executed
1279});
1280
1281test("BlockingMutation - debounce: all promises resolve together", async () => {
1282 const { client } = createTestClient();
1283 const resolvedAt: number[] = [];
1284
1285 const mutation = client.define({
1286 async mutate(_id: string, value: string) {
1287 await delay(20);
1288 return `result-${value}`;
1289 },
1290 describe: "debounced mutation",
1291 describeResult: "Success",
1292 optimistic() {},
1293
1294 debounceMs: 50,
1295 });
1296
1297 // Create three rapid calls
1298 const promise1 = mutation.runAsPromise("id", "a").then((result) => {
1299 resolvedAt.push(Date.now());
1300 return result;
1301 });
1302 const promise2 = mutation.runAsPromise("id", "b").then((result) => {
1303 resolvedAt.push(Date.now());
1304 return result;
1305 });
1306 const promise3 = mutation.runAsPromise("id", "c").then((result) => {
1307 resolvedAt.push(Date.now());
1308 return result;
1309 });
1310
1311 const results = await Promise.all([promise1, promise2, promise3]);
1312
1313 // All should resolve with the same value
1314 assertEquals(results, ["result-c", "result-c", "result-c"]);
1315
1316 // All should resolve at approximately the same time (within 10ms)
1317 assertEquals(resolvedAt.length, 3);
1318 const maxDiff = Math.max(...resolvedAt) - Math.min(...resolvedAt);
1319 assertEquals(maxDiff < 10, true);
1320});
1321
1322test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
1323 const { client } = createTestClient();
1324
1325 const mutation = client.define({
1326 async mutate(value: string) {
1327 await delay(10);
1328 return `result-${value}`;
1329 },
1330 describe: "debounced mutation",
1331 describeResult: "Success",
1332 optimistic() {},
1333
1334 debounceMs: 100,
1335 });
1336
1337 const key = mutation.key(["test"]);
1338
1339 // Subscribe and unsubscribe to create and delete the channel
1340 const unsubscribe = mutation.subscribe(key, () => {});
1341
1342 // Start a debounced call
1343 mutation.runAsPromise("test");
1344 await delay(10);
1345
1346 // Unsubscribe while debounce is pending
1347 unsubscribe();
1348
1349 // The timer should still fire and the mutation should complete
1350 await delay(120);
1351
1352 // No errors should have occurred
1353 // (If the timer wasn't cleaned up properly, we might see issues)
1354});
1355
1356test("BlockingMutation - debounce: multiple keys debounce independently", async () => {
1357 const { client } = createTestClient();
1358 const mutateArgs: string[] = [];
1359
1360 const mutation = client.define({
1361 async mutate(id: string) {
1362 mutateArgs.push(id);
1363 await delay(10);
1364 return `result-${id}`;
1365 },
1366 describe: "debounced mutation",
1367 describeResult: "Success",
1368 optimistic() {},
1369
1370 debounceMs: 50,
1371 key: ({ args }) => args[0],
1372 });
1373
1374 // Rapid calls to different keys
1375 const promise1a = mutation.runAsPromise("key1");
1376 const promise1b = mutation.runAsPromise("key1");
1377 const promise2a = mutation.runAsPromise("key2");
1378 const promise2b = mutation.runAsPromise("key2");
1379
1380 await Promise.all([promise1a, promise1b, promise2a, promise2b]);
1381
1382 // Should have executed once per key
1383 assertEquals(mutateArgs.sort(), ["key1", "key2"]);
1384});
1385
1386test("BlockingMutation - debounce: onSuccess callbacks from last call only", async () => {
1387 const { client } = createTestClient();
1388 const successResults: string[] = [];
1389
1390 const mutation = client.define({
1391 async mutate(value: string) {
1392 await delay(10);
1393 return `result-${value}`;
1394 },
1395 describe: "debounced mutation",
1396 describeResult: "Success",
1397 optimistic({ args, onSuccess }) {
1398 const [value] = args;
1399 onSuccess((result) => {
1400 successResults.push(`${value}->${result}`);
1401 });
1402 },
1403
1404 debounceMs: 50,
1405 });
1406
1407 // Make three rapid calls with different onSuccess callbacks
1408 await Promise.all([
1409 mutation.runAsPromise("a"),
1410 mutation.runAsPromise("b"),
1411 mutation.runAsPromise("c"),
1412 ]);
1413
1414 await delay(20);
1415
1416 // Only the last call's onSuccess should have been called
1417 assertEquals(successResults, ["c->result-c"]);
1418});
test/debounced.test.ts deleted-1025
...@@ -1,1025 +0,0 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
3import { MutationClient } from "../src/client.ts";
4import type { MutationEvent } from "../src/types.ts";
5
6// Shared test store for optimistic updates
7const testStore = new Map<string, number>();
8
9// Helper to create a test mutation client
10function createTestClient() {
11 const errors: Array<{ message: string; error: unknown }> = [];
12 const successes: string[] = [];
13 const client = new MutationClient({
14 context: { userId: "test-user" },
15 getOptimisticHelpers({ onRestore }) {
16 return {
17 increment(key: string, amount: number) {
18 const oldValue = testStore.get(key) ?? 0;
19 testStore.set(key, oldValue + amount);
20 onRestore(() => testStore.set(key, oldValue));
21 },
22 setValue(key: string, value: number) {
23 const oldValue = testStore.get(key);
24 testStore.set(key, value);
25 onRestore(() => {
26 if (oldValue === undefined) {
27 testStore.delete(key);
28 } else {
29 testStore.set(key, oldValue);
30 }
31 });
32 },
33 };
34 },
35 reportError(message, error) {
36 errors.push({ message, error });
37 },
38 reportSuccess(message) {
39 successes.push(message);
40 },
41 });
42
43 return { client, errors, successes };
44}
45
46// Helper to track mutation events
47function createEventTracker<Result>() {
48 const events: Array<MutationEvent<Result>> = [];
49 const callback = (event: MutationEvent<Result>) => {
50 events.push({ ...event });
51 };
52 return { events, callback };
53}
54
55// Helper to wait for async operations
56function delay(ms: number) {
57 return new Promise((resolve) => setTimeout(resolve, ms));
58}
59
60// ============================================================================
61// Basic functionality tests
62// ============================================================================
63
64test("DebouncedMutation - basic mutation success with debounce", async () => {
65 const { client } = createTestClient();
66 testStore.clear();
67 testStore.set("counter", 0);
68
69 let commitCallCount = 0;
70 let refetchCallCount = 0;
71
72 const mutation = client.defineBatched({
73 optimistic({ helpers }, amount: number) {
74 helpers.increment("counter", amount);
75 },
76 mode: "debounce",
77 time: 50,
78 key: () => "test-key",
79 getValue: (_) => testStore.get("counter") ?? 0,
80 async commit({ initial, current }) {
81 commitCallCount++;
82 await delay(10);
83 return current - initial;
84 },
85 describe: "increment counter",
86 describeResult: "Success",
87 async refetch() {
88 refetchCallCount++;
89 },
90 });
91
92 const result = await mutation.runAsPromise(5);
93 await delay(20); // Wait for refetch
94
95 assertEquals(result, 5);
96 assertEquals(commitCallCount, 1);
97 assertEquals(refetchCallCount, 1);
98 assertEquals(testStore.get("counter"), 5);
99});
100
101test("DebouncedMutation - run() catches errors", async () => {
102 const { client, errors } = createTestClient();
103 testStore.clear();
104 testStore.set("counter", 0);
105
106 const mutation = client.defineBatched({
107 optimistic({ helpers }, amount: number) {
108 helpers.increment("counter", amount);
109 },
110 mode: "debounce",
111 time: 20,
112 key: () => "test-key",
113 getValue: (_) => testStore.get("counter") ?? 0,
114 async commit() {
115 throw new Error("commit failed");
116 },
117 describe: "failing mutation",
118 describeResult: "Success",
119 });
120
121 mutation.run(5);
122 await delay(100);
123
124 assertEquals(errors.length, 1);
125 assertEquals((errors[0].error as Error).message, "commit failed");
126});
127
128test("DebouncedMutation - runAndReturn() rejects on error", async () => {
129 const { client } = createTestClient();
130 testStore.clear();
131 testStore.set("counter", 0);
132
133 const mutation = client.defineBatched({
134 optimistic({ helpers }, amount: number) {
135 helpers.increment("counter", amount);
136 },
137 mode: "debounce",
138 time: 10,
139 key: () => "test-key",
140 getValue: (_) => testStore.get("counter") ?? 0,
141 async commit() {
142 throw new Error("commit failed");
143 },
144 describe: "failing mutation",
145 describeResult: "Success",
146 });
147
148 await assertRejects(
149 () => mutation.runAsPromise(5),
150 Error,
151 "commit failed",
152 );
153});
154
155// ============================================================================
156// Debounce mode tests
157// ============================================================================
158
159test("DebouncedMutation - debounce batches rapid calls", async () => {
160 const { client } = createTestClient();
161 testStore.clear();
162 testStore.set("counter", 0);
163
164 let commitCallCount = 0;
165 const commitArgs: Array<{ initial: number; current: number }> = [];
166
167 const mutation = client.defineBatched({
168 optimistic({ helpers }, amount: number) {
169 helpers.increment("counter", amount);
170 },
171 mode: "debounce",
172 time: 50,
173 key: () => "test-key",
174 getValue: (_) => testStore.get("counter") ?? 0,
175 async commit({ initial, current }) {
176 commitCallCount++;
177 commitArgs.push({ initial, current });
178 return current - initial;
179 },
180 describe: "increment counter",
181 describeResult: "Success",
182 });
183
184 // Rapid calls within debounce window
185 const promise1 = mutation.runAsPromise(1);
186 const promise2 = mutation.runAsPromise(2);
187 const promise3 = mutation.runAsPromise(3);
188
189 // Optimistic updates should be applied immediately
190 assertEquals(testStore.get("counter"), 6);
191
192 const results = await Promise.all([promise1, promise2, promise3]);
193
194 // All should resolve with the same result (total delta)
195 assertEquals(results, [6, 6, 6]);
196
197 // Only one commit should have been made
198 assertEquals(commitCallCount, 1);
199 assertEquals(commitArgs, [{ initial: 0, current: 6 }]);
200});
201
202test("DebouncedMutation - debounce resets timer on each call", async () => {
203 const { client } = createTestClient();
204 testStore.clear();
205 testStore.set("counter", 0);
206
207 let commitCallCount = 0;
208
209 const mutation = client.defineBatched({
210 optimistic({ helpers }, amount: number) {
211 helpers.increment("counter", amount);
212 },
213 mode: "debounce",
214 time: 30,
215 key: () => "test-key",
216 getValue: (_) => testStore.get("counter") ?? 0,
217 async commit({ initial, current }) {
218 commitCallCount++;
219 return current - initial;
220 },
221 describe: "increment counter",
222 describeResult: "Success",
223 });
224
225 // First call
226 const promise1 = mutation.runAsPromise(1);
227
228 // Wait less than debounce time
229 await delay(15);
230
231 // Second call should reset the timer
232 const promise2 = mutation.runAsPromise(2);
233
234 // Wait less than debounce time again
235 await delay(15);
236
237 // Commit should not have happened yet
238 assertEquals(commitCallCount, 0);
239
240 // Third call
241 const promise3 = mutation.runAsPromise(3);
242
243 // Wait for all to complete
244 await Promise.all([promise1, promise2, promise3]);
245
246 // Only one commit
247 assertEquals(commitCallCount, 1);
248});
249
250test("DebouncedMutation - debounce separates batches after timeout", async () => {
251 const { client } = createTestClient();
252 testStore.clear();
253 testStore.set("counter", 0);
254
255 let commitCallCount = 0;
256 const commitArgs: Array<{ initial: number; current: number }> = [];
257
258 const mutation = client.defineBatched({
259 optimistic({ helpers }, amount: number) {
260 helpers.increment("counter", amount);
261 },
262 mode: "debounce",
263 time: 30,
264 key: () => "test-key",
265 getValue: (_) => testStore.get("counter") ?? 0,
266 async commit({ initial, current }) {
267 commitCallCount++;
268 commitArgs.push({ initial, current });
269 return current - initial;
270 },
271 describe: "increment counter",
272 describeResult: "Success",
273 });
274
275 // First batch
276 await mutation.runAsPromise(1);
277 await delay(50); // Wait for first batch to complete
278
279 // Second batch (after timeout)
280 await mutation.runAsPromise(2);
281 await delay(50);
282
283 // Two separate commits
284 assertEquals(commitCallCount, 2);
285 assertEquals(commitArgs, [
286 { initial: 0, current: 1 },
287 { initial: 1, current: 3 },
288 ]);
289});
290
291// ============================================================================
292// Throttle mode tests
293// ============================================================================
294
295test("DebouncedMutation - throttle commits immediately on first call", async () => {
296 const { client } = createTestClient();
297 testStore.clear();
298 testStore.set("counter", 0);
299
300 let commitTime = 0;
301 const startTime = Date.now();
302
303 const mutation = client.defineBatched({
304 optimistic({ helpers }, amount: number) {
305 helpers.increment("counter", amount);
306 },
307 mode: "throttle",
308 time: 100,
309 key: () => "test-key",
310 getValue: (_) => testStore.get("counter") ?? 0,
311 async commit({ initial, current }) {
312 commitTime = Date.now() - startTime;
313 return current - initial;
314 },
315 describe: "increment counter",
316 describeResult: "Success",
317 });
318
319 await mutation.runAsPromise(5);
320
321 // First call should commit immediately (within a small tolerance)
322 assertEquals(commitTime < 20, true);
323});
324
325test("DebouncedMutation - throttle batches calls within time window", async () => {
326 const { client } = createTestClient();
327 testStore.clear();
328 testStore.set("counter", 0);
329
330 let commitCallCount = 0;
331 const commitArgs: Array<{ initial: number; current: number }> = [];
332
333 const mutation = client.defineBatched({
334 optimistic({ helpers }, amount: number) {
335 helpers.increment("counter", amount);
336 },
337 mode: "throttle",
338 time: 100,
339 key: () => "test-key",
340 getValue: (_) => testStore.get("counter") ?? 0,
341 async commit({ initial, current }) {
342 commitCallCount++;
343 commitArgs.push({ initial, current });
344 await delay(10);
345 return current - initial;
346 },
347 describe: "increment counter",
348 describeResult: "Success",
349 });
350
351 // First call commits immediately
352 const promise1 = mutation.runAsPromise(1);
353 await delay(5);
354
355 // Second call within throttle window - should batch
356 const promise2 = mutation.runAsPromise(2);
357 await delay(5);
358
359 // Third call within throttle window - should batch with second
360 const promise3 = mutation.runAsPromise(3);
361
362 // Wait for first to complete
363 await promise1;
364
365 // First commit happened immediately
366 assertEquals(commitCallCount, 1);
367 assertEquals(commitArgs[0], { initial: 0, current: 1 });
368
369 // Wait for throttle window to pass and second batch to commit
370 await Promise.all([promise2, promise3]);
371 await delay(50);
372
373 // Second batch committed
374 assertEquals(commitCallCount, 2);
375 assertEquals(commitArgs[1], { initial: 1, current: 6 });
376});
377
378test("DebouncedMutation - throttle allows new batch after time window", async () => {
379 const { client } = createTestClient();
380 testStore.clear();
381 testStore.set("counter", 0);
382
383 let commitCallCount = 0;
384
385 const mutation = client.defineBatched({
386 optimistic({ helpers }, amount: number) {
387 helpers.increment("counter", amount);
388 },
389 mode: "throttle",
390 time: 50,
391 key: () => "test-key",
392 getValue: (_) => testStore.get("counter") ?? 0,
393 async commit({ initial, current }) {
394 commitCallCount++;
395 return current - initial;
396 },
397 describe: "increment counter",
398 describeResult: "Success",
399 });
400
401 // First call
402 await mutation.runAsPromise(1);
403 await delay(10);
404
405 assertEquals(commitCallCount, 1);
406
407 // Wait for throttle window to pass
408 await delay(60);
409
410 // Second call should commit immediately
411 await mutation.runAsPromise(2);
412 await delay(10);
413
414 assertEquals(commitCallCount, 2);
415});
416
417// ============================================================================
418// No-op detection tests
419// ============================================================================
420
421test("DebouncedMutation - skips commit when value unchanged", async () => {
422 const { client } = createTestClient();
423 testStore.clear();
424 testStore.set("counter", 5);
425
426 let commitCallCount = 0;
427
428 const mutation = client.defineBatched({
429 optimistic({ helpers }, amount: number) {
430 helpers.increment("counter", amount);
431 },
432 mode: "debounce",
433 time: 20,
434 key: () => "test-key",
435 getValue: (_) => testStore.get("counter") ?? 0,
436 async commit({ initial, current }) {
437 commitCallCount++;
438 return current - initial;
439 },
440 describe: "increment counter",
441 describeResult: "Success",
442 });
443
444 // +5 and -5 cancel out
445 const promise1 = mutation.runAsPromise(5);
446 const promise2 = mutation.runAsPromise(-5);
447
448 const [result1, result2] = await Promise.all([promise1, promise2]);
449
450 // No commit should have been made
451 assertEquals(commitCallCount, 0);
452
453 // Results should be null (no actual change)
454 assertEquals(result1, null);
455 assertEquals(result2, null);
456
457 // Store should be unchanged
458 assertEquals(testStore.get("counter"), 5);
459});
460
461test("DebouncedMutation - uses deepEquals for comparison", async () => {
462 const errors: unknown[] = [];
463 const objectStore: { value: { count: number } | null } = {
464 value: { count: 0 },
465 };
466
467 const client = new MutationClient({
468 context: {},
469 getOptimisticHelpers({ onRestore }) {
470 return {
471 setCount(count: number) {
472 const old = objectStore.value;
473 objectStore.value = { count };
474 onRestore(() => {
475 objectStore.value = old;
476 });
477 },
478 };
479 },
480 reportError(message, error) {
481 errors.push(error);
482 },
483 });
484
485 let commitCallCount = 0;
486
487 const mutation = client.defineBatched({
488 optimistic({ helpers }, count: number) {
489 helpers.setCount(count);
490 },
491 mode: "debounce",
492 time: 20,
493 key: () => "test-key",
494 getValue: (_) => objectStore.value,
495 async commit() {
496 commitCallCount++;
497 return null;
498 },
499 describe: "set count",
500 describeResult: "Success",
501 });
502
503 // Set to same value (different object reference but same content)
504 await mutation.runAsPromise(0);
505 await delay(30);
506
507 // Should skip commit because value is deeply equal
508 assertEquals(commitCallCount, 0);
509});
510
511test("DebouncedMutation - custom deepEquals function", async () => {
512 const errors: unknown[] = [];
513 let compareCallCount = 0;
514
515 const client = new MutationClient({
516 context: {},
517 getOptimisticHelpers({ onRestore }) {
518 return {
519 increment(key: string, amount: number) {
520 const old = testStore.get(key) ?? 0;
521 testStore.set(key, old + amount);
522 onRestore(() => testStore.set(key, old));
523 },
524 };
525 },
526 reportError(message, error) {
527 errors.push(error);
528 },
529 deepEquals(a, b) {
530 compareCallCount++;
531 // Custom comparison
532 return a === b;
533 },
534 });
535
536 testStore.clear();
537 testStore.set("counter", 0);
538
539 const mutation = client.defineBatched({
540 optimistic({ helpers }, amount: number) {
541 helpers.increment("counter", amount);
542 },
543 mode: "debounce",
544 time: 10,
545 key: () => "test-key",
546 getValue: (_) => testStore.get("counter") ?? 0,
547 async commit() {
548 throw new Error("commit failed");
549 },
550 describe: "failing mutation",
551 describeResult: "Success",
552 });
553
554 await mutation.runAsPromise(5).catch(() => {
555 // Expected to fail due to commit error
556 });
557 await delay(30);
558
559 // Custom deepEquals should have been called
560 assertEquals(compareCallCount > 0, true);
561});
562
563// ============================================================================
564// Rollback tests
565// ============================================================================
566
567test("DebouncedMutation - rollback on commit error", async () => {
568 const { client } = createTestClient();
569 testStore.clear();
570 testStore.set("counter", 10);
571
572 const mutation = client.defineBatched({
573 optimistic({ helpers }, amount: number) {
574 helpers.increment("counter", amount);
575 },
576 mode: "debounce",
577 time: 20,
578 key: () => "test-key",
579 getValue: (_) => testStore.get("counter") ?? 0,
580 async commit() {
581 throw new Error("commit failed");
582 },
583 describe: "failing mutation",
584 describeResult: "Success",
585 });
586
587 // Optimistic update applied
588 const promise = mutation.runAsPromise(5);
589 assertEquals(testStore.get("counter"), 15);
590
591 await assertRejects(() => promise, Error, "commit failed");
592
593 // Should be rolled back
594 assertEquals(testStore.get("counter"), 10);
595});
596
597test("DebouncedMutation - error event includes error details", async () => {
598 const { client } = createTestClient();
599 testStore.clear();
600 testStore.set("counter", 0);
601
602 const tracker = createEventTracker<number>();
603
604 const mutation = client.defineBatched({
605 optimistic({ helpers }, amount: number) {
606 helpers.increment("counter", amount);
607 },
608 mode: "debounce",
609 time: 20,
610 key: () => "test-key",
611 getValue: (_) => testStore.get("counter") ?? 0,
612 async commit() {
613 throw new Error("commit failed");
614 },
615 describe: "failing mutation",
616 describeResult: "Success",
617 });
618
619 const key = mutation.key([5]);
620 mutation.subscribe(key, tracker.callback);
621
622 await assertRejects(() => mutation.runAsPromise(5));
623 await delay(30);
624
625 // Should have error in events
626 const errorEvents = tracker.events.filter((e) => e.error !== null);
627 assertEquals(errorEvents.length > 0, true);
628 assertEquals((errorEvents[0]?.error as Error).message, "commit failed");
629});
630
631// ============================================================================
632// Key handling tests
633// ============================================================================
634
635test("DebouncedMutation - key() returns JSON stringified key", () => {
636 const { client } = createTestClient();
637 testStore.clear();
638
639 const mutation = client.defineBatched({
640 optimistic(_ctx, _id: string) {},
641 mode: "debounce",
642 time: 20,
643 key: ({ args }) => args[0],
644 getValue: (_) => 0,
645 async commit() {
646 return null;
647 },
648 describe: "test mutation",
649 describeResult: "Success",
650 });
651
652 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
653});
654
655test("DebouncedMutation - key() can return array", () => {
656 const { client } = createTestClient();
657 testStore.clear();
658
659 const mutation = client.defineBatched({
660 optimistic(_ctx, _id: string) {},
661 mode: "debounce",
662 time: 20,
663 key: ({ args }) => ["user", args[0]],
664 getValue: (_) => 0,
665 async commit() {
666 return null;
667 },
668 describe: "test mutation",
669 describeResult: "Success",
670 });
671
672 assertEquals(
673 mutation.key(["123"]),
674 JSON.stringify(["user", "123"]),
675 );
676});
677
678test("DebouncedMutation - different keys create separate batches", async () => {
679 const { client } = createTestClient();
680 testStore.clear();
681 testStore.set("counter-a", 0);
682 testStore.set("counter-b", 0);
683
684 let commitCallCount = 0;
685
686 const mutation = client.defineBatched({
687 optimistic({ helpers }, key: string, amount: number) {
688 helpers.increment(`counter-${key}`, amount);
689 },
690 mode: "debounce",
691 time: 20,
692 key: ({ args }) => args[0],
693 getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0,
694 async commit({ current }) {
695 commitCallCount++;
696 return current;
697 },
698 describe: "test mutation",
699 describeResult: "Success",
700 });
701
702 // Two different keys
703 const promise1 = mutation.runAsPromise("a", 5);
704 const promise2 = mutation.runAsPromise("b", 10);
705
706 await Promise.all([promise1, promise2]);
707 await delay(30);
708
709 // Should have two separate commits
710 assertEquals(commitCallCount, 2);
711 assertEquals(testStore.get("counter-a"), 5);
712 assertEquals(testStore.get("counter-b"), 10);
713});
714
715// ============================================================================
716// Describe tests
717// ============================================================================
718
719test("DebouncedMutation - describe() with string", () => {
720 const { client } = createTestClient();
721 testStore.clear();
722
723 const mutation = client.defineBatched({
724 optimistic(_ctx, _amount: number) {},
725 mode: "debounce",
726 time: 20,
727 key: () => "test-key",
728 getValue: (_) => 0,
729 async commit() {
730 return null;
731 },
732 describe: "update counter",
733 describeResult: "Success",
734 });
735
736 assertEquals(mutation.describe(5), "update counter");
737});
738
739test("DebouncedMutation - describe() with function", () => {
740 const { client } = createTestClient();
741 testStore.clear();
742
743 const mutation = client.defineBatched({
744 optimistic(_ctx, _amount: number) {},
745 mode: "debounce",
746 time: 20,
747 key: () => "test-key",
748 getValue: (_) => 0,
749 async commit() {
750 return null;
751 },
752 describe: ({ args }) => `increment by ${args[0]}`,
753 describeResult: "Success",
754 });
755
756 assertEquals(mutation.describe(5), "increment by 5");
757});
758
759// ============================================================================
760// Promise resolution tests
761// ============================================================================
762
763test("DebouncedMutation - all pending promises resolve with same result", async () => {
764 const { client } = createTestClient();
765 testStore.clear();
766 testStore.set("counter", 0);
767
768 const mutation = client.defineBatched({
769 optimistic({ helpers }, amount: number) {
770 helpers.increment("counter", amount);
771 },
772 mode: "debounce",
773 time: 30,
774 key: () => "test-key",
775 getValue: (_) => testStore.get("counter") ?? 0,
776 async commit({ initial, current }) {
777 return { delta: current - initial, timestamp: Date.now() };
778 },
779 describe: "increment counter",
780 describeResult: "Success",
781 });
782
783 const promise1 = mutation.runAsPromise(1);
784 const promise2 = mutation.runAsPromise(2);
785 const promise3 = mutation.runAsPromise(3);
786
787 const [result1, result2, result3] = await Promise.all([
788 promise1,
789 promise2,
790 promise3,
791 ]);
792
793 // All should get the same result object
794 assertEquals(result1, result2);
795 assertEquals(result2, result3);
796 assertEquals(result1.delta, 6);
797});
798
799test("DebouncedMutation - all pending promises reject with same error", async () => {
800 const { client } = createTestClient();
801 testStore.clear();
802 testStore.set("counter", 0);
803
804 const mutation = client.defineBatched({
805 optimistic({ helpers }, amount: number) {
806 helpers.increment("counter", amount);
807 },
808 mode: "debounce",
809 time: 30,
810 key: () => "test-key",
811 getValue: (_) => testStore.get("counter") ?? 0,
812 async commit() {
813 throw new Error("batch commit failed");
814 },
815 describe: "increment counter",
816 describeResult: "Success",
817 });
818
819 const promise1 = mutation.runAsPromise(1);
820 const promise2 = mutation.runAsPromise(2);
821 const promise3 = mutation.runAsPromise(3);
822
823 const errors: Error[] = [];
824 await Promise.all([
825 promise1.catch((e) => errors.push(e)),
826 promise2.catch((e) => errors.push(e)),
827 promise3.catch((e) => errors.push(e)),
828 ]);
829
830 // All should get the same error
831 assertEquals(errors.length, 3);
832 assertEquals(errors[0].message, "batch commit failed");
833 assertEquals(errors[1].message, "batch commit failed");
834 assertEquals(errors[2].message, "batch commit failed");
835});
836
837// ============================================================================
838// Edge case tests
839// ============================================================================
840
841test("DebouncedMutation - handles empty getValue result", async () => {
842 const { client } = createTestClient();
843 testStore.clear();
844
845 let commitCallCount = 0;
846
847 const mutation = client.defineBatched({
848 optimistic({ helpers }, amount: number) {
849 helpers.setValue("nonexistent", amount);
850 },
851 mode: "debounce",
852 time: 20,
853 key: () => "test-key",
854 getValue: (_) => testStore.get("nonexistent"),
855 async commit({ initial, current }) {
856 commitCallCount++;
857 return { initial, current };
858 },
859 describe: "test mutation",
860 describeResult: "Success",
861 });
862
863 const result = await mutation.runAsPromise(5);
864 await delay(30);
865
866 assertEquals(commitCallCount, 1);
867 assertEquals(result.initial, undefined);
868 assertEquals(result.current, 5);
869});
870
871test("DebouncedMutation - channel cleanup after idle with no listeners", async () => {
872 const { client } = createTestClient();
873 testStore.clear();
874 testStore.set("counter", 0);
875
876 const mutation = client.defineBatched({
877 optimistic({ helpers }, amount: number) {
878 helpers.increment("counter", amount);
879 },
880 mode: "debounce",
881 time: 20,
882 key: () => "test-key",
883 getValue: (_) => testStore.get("counter") ?? 0,
884 async commit({ initial, current }) {
885 return current - initial;
886 },
887 describe: "test mutation",
888 describeResult: "Success",
889 });
890
891 // Run mutation without subscribing
892 await mutation.runAsPromise(5);
893 await delay(30);
894
895 // Run another mutation - should work fine (channel recreated if needed)
896 const result = await mutation.runAsPromise(3);
897 await delay(30);
898
899 assertEquals(result, 3);
900 assertEquals(testStore.get("counter"), 8);
901});
902
903test("DebouncedMutation - default time is 200ms", async () => {
904 const { client } = createTestClient();
905 testStore.clear();
906 testStore.set("counter", 0);
907
908 let commitTime: number | null = null;
909 const startTime = Date.now();
910
911 const mutation = client.defineBatched({
912 optimistic({ helpers }, amount: number) {
913 helpers.increment("counter", amount);
914 },
915 mode: "debounce",
916 // time not specified, should default to 200
917 key: () => "test-key",
918 getValue: (_) => testStore.get("counter") ?? 0,
919 async commit({ initial, current }) {
920 commitTime = Date.now() - startTime;
921 return current - initial;
922 },
923 describe: "test mutation",
924 describeResult: "Success",
925 });
926
927 await mutation.runAsPromise(5);
928
929 // Should commit after ~200ms (with some tolerance)
930 assertEquals(commitTime !== null, true);
931 assertEquals(commitTime! >= 180, true);
932 assertEquals(commitTime! <= 250, true);
933});
934
935test("DebouncedMutation - context is passed to getValue", async () => {
936 const { client } = createTestClient();
937 testStore.clear();
938 testStore.set("counter", 0);
939
940 let receivedUserId: string | undefined;
941
942 const mutation = client.defineBatched({
943 optimistic({ helpers }, amount: number) {
944 helpers.increment("counter", amount);
945 },
946 mode: "debounce",
947 time: 20,
948 key: () => "test-key",
949 getValue: ({ userId }) => {
950 receivedUserId = userId;
951 return testStore.get("counter") ?? 0;
952 },
953 async commit({ initial, current }) {
954 return current - initial;
955 },
956 describe: "test mutation",
957 describeResult: "Success",
958 });
959
960 await mutation.runAsPromise(5);
961 await delay(30);
962
963 assertEquals(receivedUserId, "test-user");
964});
965
966test("DebouncedMutation - context is passed to commit", async () => {
967 const { client } = createTestClient();
968 testStore.clear();
969 testStore.set("counter", 0);
970
971 let receivedUserId: string | undefined;
972
973 const mutation = client.defineBatched({
974 optimistic({ helpers }, amount: number) {
975 helpers.increment("counter", amount);
976 },
977 mode: "debounce",
978 time: 20,
979 key: () => "test-key",
980 getValue: (_) => testStore.get("counter") ?? 0,
981 async commit({ userId, initial, current }) {
982 receivedUserId = userId;
983 return current - initial;
984 },
985 describe: "test mutation",
986 describeResult: "Success",
987 });
988
989 await mutation.runAsPromise(5);
990 await delay(30);
991
992 assertEquals(receivedUserId, "test-user");
993});
994
995test("DebouncedMutation - first args are used for commit", async () => {
996 const { client } = createTestClient();
997 testStore.clear();
998 testStore.set("counter", 0);
999
1000 let receivedArgs: [string, number] | undefined;
1001
1002 const mutation = client.defineBatched({
1003 optimistic({ helpers }, _label: string, amount: number) {
1004 helpers.increment("counter", amount);
1005 },
1006 mode: "debounce",
1007 time: 30,
1008 key: () => "test-key",
1009 getValue: (_) => testStore.get("counter") ?? 0,
1010 async commit({ args, initial, current }) {
1011 receivedArgs = args;
1012 return current - initial;
1013 },
1014 describe: "test mutation",
1015 describeResult: "Success",
1016 });
1017
1018 mutation.runAsPromise("first", 1);
1019 mutation.runAsPromise("second", 2);
1020 await mutation.runAsPromise("third", 3);
1021 await delay(10);
1022
1023 // Should use first args
1024 assertEquals(receivedArgs, ["first", 1]);
1025});
test/react-button.test.tsx deleted-724
...@@ -1,724 +0,0 @@
1import { render, screen, waitFor } from "@testing-library/react";
2import { userEvent } from "@testing-library/user-event";
3import type { FC } from "react";
4import { describe, expect, test, vi } from "vitest";
5import { MutationClient } from "../src/client.ts";
6import { createMutationButton, useMutate } from "../src/react.ts";
7
8// Helper to create a test mutation client
9function createTestClient() {
10 const errors: Array<{ message: string; error: unknown }> = [];
11 const successes: string[] = [];
12 const client = new MutationClient({
13 context: { userId: "test-user" },
14 getOptimisticHelpers({ onRestore }) {
15 return {};
16 },
17 reportError(message, error) {
18 errors.push({ message, error });
19 },
20 reportSuccess(message) {
21 successes.push(message);
22 },
23 });
24
25 return { client, errors, successes };
26}
27
28// Helper to wait for async operations
29function delay(ms: number) {
30 return new Promise((resolve) => setTimeout(resolve, ms));
31}
32
33// Test button component
34interface TestButtonProps {
35 onClick?: (e: React.MouseEvent) => void;
36 isPending: boolean;
37 children: React.ReactNode;
38 disabled?: boolean;
39 variant?: "primary" | "secondary";
40}
41
42const TestButton: FC<TestButtonProps> = ({
43 onClick,
44 isPending,
45 children,
46 disabled,
47 variant,
48}) => {
49 return (
50 <button onClick={onClick} disabled={disabled || isPending} data-variant={variant}>
51 {isPending ? "Loading..." : children}
52 </button>
53 );
54};
55
56describe("createMutationButton - Basic Functionality", () => {
57 test("should create a mutation button component", () => {
58 const MutationButton = createMutationButton(TestButton);
59 expect(MutationButton).toBeDefined();
60 expect(MutationButton.displayName).toBe("MutationButton[TestButton]");
61 });
62
63 test("should execute mutation with static args", async () => {
64 const { client } = createTestClient();
65
66 const mutation = client.define({
67 async mutate(value: string) {
68 await delay(10);
69 return `result-${value}`;
70 },
71 describe: "test mutation",
72 optimistic() {},
73 });
74
75 const MutationButton = createMutationButton(TestButton);
76
77 function Component() {
78 return (
79 <MutationButton mutation={mutation} args={["test-value"]}>
80 Click Me
81 </MutationButton>
82 );
83 }
84
85 render(<Component />);
86
87 const button = screen.getByText("Click Me");
88 await userEvent.click(button);
89
90 await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy());
91
92 await waitFor(() => expect(screen.getByText("Click Me")).toBeTruthy());
93 });
94
95 test("should execute mutation with dynamic args from function", async () => {
96 const { client } = createTestClient();
97 const argsSpy = vi.fn();
98
99 const mutation = client.define({
100 async mutate(value: string) {
101 argsSpy(value);
102 await delay(10);
103 return `result-${value}`;
104 },
105 describe: "test mutation",
106 optimistic() {},
107 });
108
109 const MutationButton = createMutationButton(TestButton);
110
111 function Component() {
112 return (
113 <MutationButton
114 mutation={mutation}
115 args={(e) => {
116 return [`dynamic-${Date.now()}`];
117 }}
118 >
119 Click Me
120 </MutationButton>
121 );
122 }
123
124 render(<Component />);
125
126 const button = screen.getByText("Click Me");
127 await userEvent.click(button);
128
129 await waitFor(() => expect(argsSpy).toHaveBeenCalled());
130 expect(argsSpy.mock.calls[0][0]).toMatch(/^dynamic-/);
131 });
132
133 test("should forward custom props to base component", () => {
134 const { client } = createTestClient();
135
136 const mutation = client.define({
137 async mutate(value: string) {
138 return value;
139 },
140 describe: "test mutation",
141 optimistic() {},
142 });
143
144 const MutationButton = createMutationButton(TestButton);
145
146 function Component() {
147 return (
148 <MutationButton
149 mutation={mutation}
150 args={["test"]}
151 variant="primary"
152 disabled={true}
153 >
154 Click Me
155 </MutationButton>
156 );
157 }
158
159 render(<Component />);
160
161 const button = screen.getByText("Click Me");
162 expect(button.getAttribute("data-variant")).toBe("primary");
163 expect(button.hasAttribute("disabled")).toBe(true);
164 });
165});
166
167describe("createMutationButton - onClick Behavior", () => {
168 test("should call custom onClick before mutation", async () => {
169 const { client } = createTestClient();
170 const onClickSpy = vi.fn();
171
172 const mutation = client.define({
173 async mutate(value: string) {
174 await delay(10);
175 return value;
176 },
177 describe: "test mutation",
178 optimistic() {},
179 });
180
181 const MutationButton = createMutationButton(TestButton);
182
183 function Component() {
184 return (
185 <MutationButton
186 mutation={mutation}
187 args={["test"]}
188 onClick={onClickSpy}
189 >
190 Click Me
191 </MutationButton>
192 );
193 }
194
195 render(<Component />);
196
197 const button = screen.getByText("Click Me");
198 await userEvent.click(button);
199
200 expect(onClickSpy).toHaveBeenCalled();
201 });
202
203 test("should prevent mutation if onClick calls preventDefault", async () => {
204 const { client } = createTestClient();
205 const mutateSpy = vi.fn();
206
207 const mutation = client.define({
208 async mutate(value: string) {
209 mutateSpy(value);
210 return value;
211 },
212 describe: "test mutation",
213 optimistic() {},
214 });
215
216 const MutationButton = createMutationButton(TestButton);
217
218 function Component() {
219 return (
220 <MutationButton
221 mutation={mutation}
222 args={["test"]}
223 onClick={(e) => e.preventDefault()}
224 >
225 Click Me
226 </MutationButton>
227 );
228 }
229
230 render(<Component />);
231
232 const button = screen.getByText("Click Me");
233 await userEvent.click(button);
234
235 await delay(50);
236
237 // Mutation should NOT be called
238 expect(mutateSpy).not.toHaveBeenCalled();
239 });
240
241 test("should prevent mutation if args function calls preventDefault", async () => {
242 const { client } = createTestClient();
243 const mutateSpy = vi.fn();
244
245 const mutation = client.define({
246 async mutate(value: string) {
247 mutateSpy(value);
248 return value;
249 },
250 describe: "test mutation",
251 optimistic() {},
252 });
253
254 const MutationButton = createMutationButton(TestButton);
255
256 function Component() {
257 return (
258 <MutationButton
259 mutation={mutation}
260 args={(e) => {
261 e.preventDefault();
262 return ["test"];
263 }}
264 >
265 Click Me
266 </MutationButton>
267 );
268 }
269
270 render(<Component />);
271
272 const button = screen.getByText("Click Me");
273 await userEvent.click(button);
274
275 await delay(50);
276
277 // Mutation should NOT be called
278 expect(mutateSpy).not.toHaveBeenCalled();
279 });
280
281 test("should prevent mutation if args function returns null", async () => {
282 const { client } = createTestClient();
283 const mutateSpy = vi.fn();
284
285 const mutation = client.define({
286 async mutate(value: string) {
287 mutateSpy(value);
288 return value;
289 },
290 describe: "test mutation",
291 optimistic() {},
292 });
293
294 const MutationButton = createMutationButton(TestButton);
295
296 function Component() {
297 return (
298 <MutationButton
299 mutation={mutation}
300 args={(e) => {
301 // Conditional args - return null to prevent mutation
302 return Math.random() > 0.5 ? ["test"] : null;
303 }}
304 >
305 Click Me
306 </MutationButton>
307 );
308 }
309
310 render(<Component />);
311
312 const button = screen.getByText("Click Me");
313
314 // Click multiple times to test the conditional logic
315 await userEvent.click(button);
316 await userEvent.click(button);
317 await userEvent.click(button);
318
319 await delay(50);
320
321 // Mutation may or may not be called depending on random
322 // This test just ensures null args don't crash
323 });
324});
325
326describe("createMutationButton - Callback Handlers", () => {
327 test("should call onSuccess callback on successful mutation", async () => {
328 const { client } = createTestClient();
329 const onSuccessSpy = vi.fn();
330
331 const mutation = client.define({
332 async mutate(value: string) {
333 await delay(10);
334 return `result-${value}`;
335 },
336 describe: "test mutation",
337 optimistic() {},
338 });
339
340 const MutationButton = createMutationButton(TestButton);
341
342 function Component() {
343 return (
344 <MutationButton
345 mutation={mutation}
346 args={["test"]}
347 onSuccess={onSuccessSpy}
348 >
349 Click Me
350 </MutationButton>
351 );
352 }
353
354 render(<Component />);
355
356 const button = screen.getByText("Click Me");
357 await userEvent.click(button);
358
359 await waitFor(() => expect(onSuccessSpy).toHaveBeenCalled());
360 expect(onSuccessSpy).toHaveBeenCalledWith("result-test");
361 });
362
363 test("should call onError callback on failed mutation", async () => {
364 const { client } = createTestClient();
365 const onErrorSpy = vi.fn();
366
367 const mutation = client.define({
368 async mutate(value: string) {
369 throw new Error("Test error");
370 },
371 describe: "test mutation",
372 optimistic() {},
373 });
374
375 const MutationButton = createMutationButton(TestButton);
376
377 function Component() {
378 return (
379 <MutationButton
380 mutation={mutation}
381 args={["test"]}
382 onError={onErrorSpy}
383 >
384 Click Me
385 </MutationButton>
386 );
387 }
388
389 render(<Component />);
390
391 const button = screen.getByText("Click Me");
392 await userEvent.click(button);
393
394 await waitFor(() => expect(onErrorSpy).toHaveBeenCalled());
395 expect(onErrorSpy.mock.calls[0][0]).toBeInstanceOf(Error);
396 });
397
398 test("should call onSettled callback on success", async () => {
399 const { client } = createTestClient();
400 const onSettledSpy = vi.fn();
401
402 const mutation = client.define({
403 async mutate(value: string) {
404 await delay(10);
405 return `result-${value}`;
406 },
407 describe: "test mutation",
408 optimistic() {},
409 });
410
411 const MutationButton = createMutationButton(TestButton);
412
413 function Component() {
414 return (
415 <MutationButton
416 mutation={mutation}
417 args={["test"]}
418 onSettled={onSettledSpy}
419 >
420 Click Me
421 </MutationButton>
422 );
423 }
424
425 render(<Component />);
426
427 const button = screen.getByText("Click Me");
428 await userEvent.click(button);
429
430 await waitFor(() => expect(onSettledSpy).toHaveBeenCalled());
431 expect(onSettledSpy.mock.calls[0][0]).toEqual({
432 status: "success",
433 result: "result-test",
434 });
435 });
436
437 test("should call onSettled callback on error", async () => {
438 const { client } = createTestClient();
439 const onSettledSpy = vi.fn();
440
441 const mutation = client.define({
442 async mutate(value: string) {
443 throw new Error("Test error");
444 },
445 describe: "test mutation",
446 optimistic() {},
447 });
448
449 const MutationButton = createMutationButton(TestButton);
450
451 function Component() {
452 return (
453 <MutationButton
454 mutation={mutation}
455 args={["test"]}
456 onSettled={onSettledSpy}
457 >
458 Click Me
459 </MutationButton>
460 );
461 }
462
463 render(<Component />);
464
465 const button = screen.getByText("Click Me");
466 await userEvent.click(button);
467
468 await waitFor(() => expect(onSettledSpy).toHaveBeenCalled());
469 expect(onSettledSpy.mock.calls[0][0]).toMatchObject({
470 status: "error",
471 });
472 expect(onSettledSpy.mock.calls[0][0].error).toBeInstanceOf(Error);
473 });
474
475 test("should prevent global handlers when local handlers are provided", async () => {
476 const { client, errors, successes } = createTestClient();
477 const onSuccessSpy = vi.fn();
478
479 const mutation = client.define({
480 async mutate(value: string) {
481 await delay(10);
482 return `result-${value}`;
483 },
484 describe: "test mutation",
485 describeResult: () => "Success message",
486 optimistic() {},
487 });
488
489 const MutationButton = createMutationButton(TestButton);
490
491 function Component() {
492 return (
493 <MutationButton
494 mutation={mutation}
495 args={["test"]}
496 onSuccess={onSuccessSpy}
497 >
498 Click Me
499 </MutationButton>
500 );
501 }
502
503 render(<Component />);
504
505 const button = screen.getByText("Click Me");
506 await userEvent.click(button);
507
508 await waitFor(() => expect(onSuccessSpy).toHaveBeenCalled());
509
510 // Global success handler should still be called per documentation
511 // "Global event handlers will still be called!"
512 // This is actually testing current behavior - may need verification
513 });
514});
515
516describe("createMutationButton - UseMutateResult Integration", () => {
517 test("should accept UseMutateResult instead of Mutation", async () => {
518 const { client } = createTestClient();
519
520 const mutation = client.define({
521 async mutate(value: string) {
522 await delay(10);
523 return `result-${value}`;
524 },
525 describe: "test mutation",
526 optimistic() {},
527 });
528
529 const MutationButton = createMutationButton(TestButton);
530
531 function Component() {
532 const mutateResult = useMutate(mutation);
533
534 return (
535 <div>
536 <MutationButton mutation={mutateResult} args={["test"]}>
537 Click Me
538 </MutationButton>
539 {mutateResult.isSuccess && <div>Success!</div>}
540 </div>
541 );
542 }
543
544 render(<Component />);
545
546 const button = screen.getByText("Click Me");
547 await userEvent.click(button);
548
549 await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy());
550 });
551
552 test("should show isPending state from useMutate", async () => {
553 const { client } = createTestClient();
554
555 const mutation = client.define({
556 async mutate(value: string) {
557 await delay(50);
558 return `result-${value}`;
559 },
560 describe: "test mutation",
561 optimistic() {},
562 });
563
564 const MutationButton = createMutationButton(TestButton);
565
566 function Component() {
567 const mutateResult = useMutate(mutation);
568
569 return (
570 <MutationButton mutation={mutateResult} args={["test"]}>
571 Click Me
572 </MutationButton>
573 );
574 }
575
576 render(<Component />);
577
578 const button = screen.getByText("Click Me");
579 await userEvent.click(button);
580
581 await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy());
582
583 await waitFor(() => expect(screen.getByText("Click Me")).toBeTruthy(), {
584 timeout: 100,
585 });
586 });
587});
588
589describe("createMutationButton - Edge Cases", () => {
590 test("should handle rapid clicks", async () => {
591 const { client } = createTestClient();
592 let callCount = 0;
593 let completedCount = 0;
594
595 const mutation = client.define({
596 async mutate(value: string) {
597 callCount++;
598 await delay(10);
599 completedCount++;
600 return `result-${callCount}`;
601 },
602 describe: "test mutation",
603 optimistic() {},
604 });
605
606 const MutationButton = createMutationButton(TestButton);
607
608 function Component() {
609 const { isPending } = useMutate(mutation);
610 return (
611 <div>
612 <MutationButton mutation={mutation} args={["test"]}>
613 Click Me
614 </MutationButton>
615 <div data-testid="pending-status">{isPending ? "busy" : "idle"}</div>
616 </div>
617 );
618 }
619
620 render(<Component />);
621
622 const button = screen.getByText("Click Me");
623
624 // Rapid clicks
625 await userEvent.click(button);
626 await userEvent.click(button);
627 await userEvent.click(button);
628
629 // Wait for all mutations to complete and return to idle
630 await waitFor(
631 () => {
632 expect(completedCount).toBeGreaterThan(0);
633 expect(screen.getByTestId("pending-status").textContent).toBe("idle");
634 },
635 { timeout: 200 },
636 );
637
638 // All clicks should be processed
639 expect(callCount).toBeGreaterThan(0);
640 });
641
642 test("should handle component unmount during mutation", async () => {
643 const { client } = createTestClient();
644
645 const mutation = client.define({
646 async mutate(value: string) {
647 await delay(50);
648 return `result-${value}`;
649 },
650 describe: "test mutation",
651 optimistic() {},
652 });
653
654 const MutationButton = createMutationButton(TestButton);
655
656 function Component() {
657 return (
658 <MutationButton mutation={mutation} args={["test"]}>
659 Click Me
660 </MutationButton>
661 );
662 }
663
664 const { unmount } = render(<Component />);
665
666 const button = screen.getByText("Click Me");
667 await userEvent.click(button);
668
669 await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy());
670
671 // Unmount while mutation is pending
672 unmount();
673
674 // Should not throw or cause errors
675 await delay(100);
676 });
677
678 test("should handle args function throwing error", async () => {
679 const { client } = createTestClient();
680 const mutateSpy = vi.fn();
681 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
682
683 const mutation = client.define({
684 async mutate(value: string) {
685 mutateSpy(value);
686 return value;
687 },
688 describe: "test mutation",
689 optimistic() {},
690 });
691
692 const MutationButton = createMutationButton(TestButton);
693
694 function Component() {
695 return (
696 <MutationButton
697 mutation={mutation}
698 args={(e) => {
699 throw new Error("Args error");
700 }}
701 >
702 Click Me
703 </MutationButton>
704 );
705 }
706
707 render(<Component />);
708
709 const button = screen.getByText("Click Me");
710
711 // Click will trigger the error in args function
712 // React will catch it and log to console
713 try {
714 await userEvent.click(button);
715 } catch (e) {
716 // Error is expected
717 }
718
719 // Mutation should NOT be called since args threw
720 expect(mutateSpy).not.toHaveBeenCalled();
721
722 consoleErrorSpy.mockRestore();
723 });
724});
test/react.test.tsx deleted-1230
...@@ -1,1230 +0,0 @@
1import { assertEquals } from "@std/assert";
2import { render, screen, waitFor } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";
4import { useState } from "react";
5import { beforeEach, describe, expect, test, vi } from "vitest";
6import { MutationClient } from "../src/client.ts";
7import { createMutationButton, useMutate } from "../src/react.ts";
8import type { Mutation } from "../src/types.ts";
9
10// Helper to create a test mutation client
11function createTestClient() {
12 const errors: Array<{ message: string; error: unknown }> = [];
13 const successes: string[] = [];
14 const client = new MutationClient({
15 context: { userId: "test-user" },
16 getOptimisticHelpers({ onRestore }) {
17 return {
18 setValue(key: string, value: string) {
19 testStore.set(key, value);
20 onRestore(() => testStore.delete(key));
21 },
22 };
23 },
24 reportError(message, error) {
25 errors.push({ message, error });
26 },
27 reportSuccess(message) {
28 successes.push(message);
29 },
30 });
31
32 return { client, errors, successes };
33}
34
35const testStore = new Map<string, string>();
36
37// Helper to wait for async operations
38function delay(ms: number) {
39 return new Promise((resolve) => setTimeout(resolve, ms));
40}
41
42describe("Observer Class - Watched Set Mechanism", () => {
43 test("should only trigger re-render when watched properties change", async () => {
44 const { client } = createTestClient();
45 let renderCount = 0;
46
47 const mutation = client.define({
48 async mutate(value: string) {
49 await delay(10);
50 return `result-${value}`;
51 },
52 describe: "test mutation",
53 optimistic() {},
54 });
55
56 function Component() {
57 renderCount++;
58 const { run, isPending } = useMutate(mutation);
59 // Only watching isPending, so changes to result/error shouldn't trigger re-render
60 return (
61 <button onClick={() => run("test")}>
62 {isPending ? "Loading..." : "Click"}
63 </button>
64 );
65 }
66
67 render(<Component />);
68 const initialRenders = renderCount;
69
70 const button = screen.getByText("Click");
71 await userEvent.click(button);
72
73 // Should re-render when isPending becomes true
74 await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy());
75 expect(renderCount).toBeGreaterThan(initialRenders);
76
77 const rendersAfterPending = renderCount;
78
79 // Should re-render when isPending becomes false
80 await waitFor(() => expect(screen.getByText("Click")).toBeTruthy());
81
82 // Should have exactly 2 more renders (pending true, pending false)
83 // NOT re-rendering for result/error changes since they're not watched
84 expect(renderCount).toBe(rendersAfterPending + 1);
85
86 // Wait a bit more to ensure all async operations complete
87 await delay(10);
88 });
89
90 test("should track multiple watched properties independently", async () => {
91 const { client } = createTestClient();
92 const watchedProperties = new Set<string>();
93
94 const mutation = client.define({
95 async mutate(value: string) {
96 await delay(10);
97 return `result-${value}`;
98 },
99 describe: "test mutation",
100 optimistic() {},
101 });
102
103 function Component() {
104 const state = useMutate(mutation);
105
106 // Access multiple properties
107 const { run, isPending, isSuccess, result } = state;
108
109 return (
110 <div>
111 <button onClick={() => run("test")}>Run</button>
112 <div>Pending: {isPending.toString()}</div>
113 <div>Success: {isSuccess.toString()}</div>
114 <div>Result: {result ?? "none"}</div>
115 </div>
116 );
117 }
118
119 render(<Component />);
120
121 // All accessed properties should cause re-renders
122 const button = screen.getByText("Run");
123 await userEvent.click(button);
124
125 await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy());
126
127 await waitFor(() => {
128 expect(screen.getByText("Success: true")).toBeTruthy();
129 expect(screen.getByText("Result: result-test")).toBeTruthy();
130 });
131
132 // Wait for all async operations to complete
133 await delay(10);
134 });
135
136 test("should not re-render when unwatched properties change", async () => {
137 const { client } = createTestClient();
138 let renderCount = 0;
139
140 const mutation = client.define({
141 async mutate(value: string) {
142 await delay(10);
143 if (value === "error") throw new Error("Test error");
144 return `result-${value}`;
145 },
146 describe: "test mutation",
147 optimistic() {},
148 });
149
150 function Component() {
151 renderCount++;
152 const { run } = useMutate(mutation);
153 // NOT watching isPending, isSuccess, result, isError, error
154 return <button onClick={() => run("success")}>Run</button>;
155 }
156
157 render(<Component />);
158 const initialRenders = renderCount;
159
160 const button = screen.getByText("Run");
161 await userEvent.click(button);
162
163 await delay(50);
164
165 // Should not have re-rendered since we're not watching any state
166 expect(renderCount).toBe(initialRenders);
167 });
168});
169
170describe("Observer Class - State Deduplication", () => {
171 test("should not trigger re-render if state value hasn't changed", async () => {
172 const { client } = createTestClient();
173 let renderCount = 0;
174
175 const mutation = client.define({
176 async mutate(value: string) {
177 await delay(5);
178 return value;
179 },
180 describe: "test mutation",
181 optimistic() {},
182 });
183
184 function Component() {
185 renderCount++;
186 const { run, status } = useMutate(mutation);
187 return (
188 <div>
189 <button onClick={() => run("test")}>Run</button>
190 <div>Status: {status}</div>
191 </div>
192 );
193 }
194
195 render(<Component />);
196 const initialRenders = renderCount;
197
198 // Status starts as "idle"
199 expect(screen.getByText("Status: idle")).toBeTruthy();
200
201 // Even if we force a state update with the same values,
202 // it shouldn't re-render
203 await delay(10);
204
205 // Render count should still be initial
206 expect(renderCount).toBe(initialRenders);
207 });
208});
209
210describe("Observer Class - Subscription Management", () => {
211 test("should unsubscribe when mutation changes", async () => {
212 const { client } = createTestClient();
213
214 const mutation1 = client.define({
215 async mutate(value: string) {
216 await delay(10);
217 return `mut1-${value}`;
218 },
219 describe: "mutation 1",
220 optimistic() {},
221 });
222
223 const mutation2 = client.define({
224 async mutate(value: string) {
225 await delay(10);
226 return `mut2-${value}`;
227 },
228 describe: "mutation 2",
229 optimistic() {},
230 });
231
232 function Component({ useMut1 }: { useMut1: boolean }) {
233 const { run, result, isSuccess } = useMutate(useMut1 ? mutation1 : mutation2);
234 return (
235 <div>
236 <button onClick={() => run("test")}>Run</button>
237 {isSuccess && <div>Result: {result}</div>}
238 </div>
239 );
240 }
241
242 const { rerender } = render(<Component useMut1={true} />);
243
244 const button = screen.getByText("Run");
245 await userEvent.click(button);
246
247 await waitFor(() => expect(screen.getByText("Result: mut1-test")).toBeTruthy());
248
249 // Change to mutation2
250 rerender(<Component useMut1={false} />);
251
252 // State should be reset
253 await waitFor(() => expect(screen.queryByText("Result: mut1-test")).toBeNull());
254
255 // Run mutation2
256 await userEvent.click(button);
257 await waitFor(() => expect(screen.getByText("Result: mut2-test")).toBeTruthy());
258 });
259
260 test("should unsubscribe when component unmounts", async () => {
261 const { client } = createTestClient();
262 const unsubscribeSpy = vi.fn();
263
264 const mutation = client.define({
265 async mutate(value: string) {
266 await delay(50); // Longer delay to ensure mutation is in progress
267 return `result-${value}`;
268 },
269 describe: "test mutation",
270 optimistic() {},
271 });
272
273 // Spy on the subscribe method
274 const originalSubscribe = mutation.subscribe.bind(mutation);
275 mutation.subscribe = (key, callback) => {
276 const unsub = originalSubscribe(key, callback);
277 return () => {
278 unsubscribeSpy();
279 unsub();
280 };
281 };
282
283 function Component() {
284 const { run, isPending } = useMutate(mutation);
285 return (
286 <button onClick={() => run("test")}>
287 {isPending ? "Loading" : "Run"}
288 </button>
289 );
290 }
291
292 const { unmount } = render(<Component />);
293
294 const button = screen.getByText("Run");
295 await userEvent.click(button);
296
297 // Wait for mutation to start
298 await waitFor(() => expect(screen.getByText("Loading")).toBeTruthy());
299
300 // Unmount while mutation is in progress
301 unmount();
302
303 // Unsubscribe should have been called
304 await waitFor(() => expect(unsubscribeSpy).toHaveBeenCalled());
305 });
306
307 test("should handle key changes and resubscribe", async () => {
308 const { client } = createTestClient();
309
310 const mutation = client.define({
311 async mutate(id: string, value: string) {
312 await delay(10);
313 return `${id}:${value}`;
314 },
315 key: ({ args: [id] }) => id,
316 describe: "test mutation",
317 optimistic() {},
318 });
319
320 function Component() {
321 const [id, setId] = useState("key1");
322 const { run, result, isSuccess } = useMutate(mutation);
323
324 return (
325 <div>
326 <button onClick={() => run(id, "data")}>Run {id}</button>
327 <button onClick={() => setId(id === "key1" ? "key2" : "key1")}>
328 Switch Key
329 </button>
330 {isSuccess && <div>Result: {result}</div>}
331 </div>
332 );
333 }
334
335 render(<Component />);
336
337 // Run with key1
338 const runButton = screen.getByText("Run key1");
339 await userEvent.click(runButton);
340
341 await waitFor(() => expect(screen.getByText("Result: key1:data")).toBeTruthy());
342
343 // Switch to key2
344 const switchButton = screen.getByText("Switch Key");
345 await userEvent.click(switchButton);
346
347 // Run with key2
348 const runButton2 = screen.getByText("Run key2");
349 await userEvent.click(runButton2);
350
351 await waitFor(() => expect(screen.getByText("Result: key2:data")).toBeTruthy());
352 });
353});
354
355describe("Observer Class - Error Message Computation", () => {
356 test("should compute error message with mutation description", async () => {
357 const { client } = createTestClient();
358
359 const mutation = client.define({
360 async mutate(itemId: string) {
361 throw new Error("Network timeout");
362 },
363 describe: (itemId: string) => `delete item ${itemId}`,
364 optimistic() {},
365 });
366
367 function Component() {
368 const { run, errorMessage, isError } = useMutate(mutation);
369 return (
370 <div>
371 <button onClick={() => run("item-123")}>Delete</button>
372 {isError && <div data-testid="error-display">Error: {errorMessage}</div>}
373 </div>
374 );
375 }
376
377 const { container } = render(<Component />);
378
379 const button = screen.getByText("Delete");
380 await userEvent.click(button);
381
382 await waitFor(() => {
383 const errorDiv = screen.getByTestId("error-display");
384 expect(errorDiv).toBeTruthy();
385 // Should contain the error message
386 expect(container.textContent).toContain("Network timeout");
387 });
388 });
389
390 test("should handle error message when mutation is null", async () => {
391 const { client } = createTestClient();
392
393 function Component() {
394 const { setError, errorMessage, isError } = useMutate(null);
395 return (
396 <div>
397 <button onClick={() => setError(new Error("Manual error"))}>
398 Set Error
399 </button>
400 {isError && <div>Error: {errorMessage}</div>}
401 </div>
402 );
403 }
404
405 render(<Component />);
406
407 const button = screen.getByText("Set Error");
408 await userEvent.click(button);
409
410 await waitFor(() => {
411 expect(screen.getByText("Error: Manual error")).toBeTruthy();
412 });
413 });
414});
415
416describe("useMutate Hook - Basic Usage", () => {
417 test("should return initial idle state", () => {
418 const { client } = createTestClient();
419
420 const mutation = client.define({
421 async mutate(value: string) {
422 return value;
423 },
424 describe: "test mutation",
425 optimistic() {},
426 });
427
428 function Component() {
429 const state = useMutate(mutation);
430 return (
431 <div>
432 <div>Status: {state.status}</div>
433 <div>IsPending: {state.isPending.toString()}</div>
434 <div>IsSuccess: {state.isSuccess.toString()}</div>
435 <div>IsError: {state.isError.toString()}</div>
436 <div>IsMutating: {state.isMutating.toString()}</div>
437 <div>IsOptimisticData: {state.isOptimisticData.toString()}</div>
438 </div>
439 );
440 }
441
442 render(<Component />);
443
444 expect(screen.getByText("Status: idle")).toBeTruthy();
445 expect(screen.getByText("IsPending: false")).toBeTruthy();
446 expect(screen.getByText("IsSuccess: false")).toBeTruthy();
447 expect(screen.getByText("IsError: false")).toBeTruthy();
448 expect(screen.getByText("IsMutating: false")).toBeTruthy();
449 expect(screen.getByText("IsOptimisticData: false")).toBeTruthy();
450 });
451
452 test("should handle null mutation", () => {
453 function Component() {
454 const { run, status } = useMutate(null);
455 return (
456 <div>
457 <button onClick={() => run()}>Run</button>
458 <div>Status: {status}</div>
459 </div>
460 );
461 }
462
463 render(<Component />);
464
465 expect(screen.getByText("Status: idle")).toBeTruthy();
466
467 // Clicking should not throw
468 const button = screen.getByText("Run");
469 expect(() => userEvent.click(button)).not.toThrow();
470 });
471
472 test("should transition through states correctly", async () => {
473 const { client } = createTestClient();
474 const states: string[] = [];
475
476 const mutation = client.define({
477 async mutate(value: string) {
478 await delay(20);
479 return `result-${value}`;
480 },
481 describe: "test mutation",
482 optimistic() {},
483 });
484
485 function Component() {
486 const { run, status, isPending, isSuccess } = useMutate(mutation);
487
488 states.push(status);
489
490 return (
491 <div>
492 <button onClick={() => run("test")}>Run</button>
493 <div>Status: {status}</div>
494 <div>Pending: {isPending.toString()}</div>
495 {isSuccess && <div>Success!</div>}
496 </div>
497 );
498 }
499
500 render(<Component />);
501
502 const button = screen.getByText("Run");
503 await userEvent.click(button);
504
505 // Should go to mutating
506 await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy());
507
508 // Should complete to success
509 await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy());
510
511 // States should include: idle -> mutating -> success
512 expect(states).toContain("idle");
513 expect(states).toContain("mutating");
514 expect(states).toContain("success");
515 });
516});
517
518describe("useMutate Hook - Error Handling", () => {
519 test("should handle errors locally when error properties are watched", async () => {
520 const { client, errors } = createTestClient();
521
522 const mutation = client.define({
523 async mutate(value: string) {
524 throw new Error("Test error");
525 },
526 describe: "failing mutation",
527 optimistic() {},
528 });
529
530 function Component() {
531 const { run, errorMessage, isError } = useMutate(mutation);
532 return (
533 <div>
534 <button onClick={() => run("test")}>Run</button>
535 {isError && <div data-testid="local-error">Local Error: {errorMessage}</div>}
536 </div>
537 );
538 }
539
540 const { container } = render(<Component />);
541
542 const button = screen.getByText("Run");
543 await userEvent.click(button);
544
545 // Error should be displayed locally
546 await waitFor(() => {
547 expect(screen.getByTestId("local-error")).toBeTruthy();
548 expect(container.textContent).toContain("Test error");
549 });
550
551 // Global error handler should NOT be called
552 expect(errors.length).toBe(0);
553 });
554
555 test("should call global error handler when error is not watched", async () => {
556 const { client, errors } = createTestClient();
557
558 const mutation = client.define({
559 async mutate(value: string) {
560 throw new Error("Test error");
561 },
562 describe: "failing mutation",
563 optimistic() {},
564 });
565
566 function Component() {
567 const { run, isPending } = useMutate(mutation);
568 // NOT watching error, errorMessage, or isError
569 return (
570 <button onClick={() => run("test")}>
571 {isPending ? "Loading" : "Run"}
572 </button>
573 );
574 }
575
576 render(<Component />);
577
578 const button = screen.getByText("Run");
579 await userEvent.click(button);
580
581 await delay(50);
582
583 // Global error handler SHOULD be called
584 await waitFor(() => expect(errors.length).toBe(1));
585 expect(errors[0].message).toContain("Failed to failing mutation");
586 });
587
588 test("should handle watching only error property (not errorMessage)", async () => {
589 const { client, errors } = createTestClient();
590
591 const mutation = client.define({
592 async mutate(value: string) {
593 throw new Error("Test error");
594 },
595 describe: "failing mutation",
596 optimistic() {},
597 });
598
599 function Component() {
600 const { run, error } = useMutate(mutation);
601 // Only watching error, not errorMessage or isError
602 return (
603 <div>
604 <button onClick={() => run("test")}>Run</button>
605 {error && <div>Has Error</div>}
606 </div>
607 );
608 }
609
610 render(<Component />);
611
612 const button = screen.getByText("Run");
613 await userEvent.click(button);
614
615 await waitFor(() => expect(screen.getByText("Has Error")).toBeTruthy());
616
617 // Should NOT call global handler since error is watched
618 expect(errors.length).toBe(0);
619 });
620});
621
622describe("useMutate Hook - Success Handling", () => {
623 test("should handle success locally when result is watched", async () => {
624 const { client, successes } = createTestClient();
625
626 const mutation = client.define({
627 async mutate(value: string) {
628 return `result-${value}`;
629 },
630 describe: "test mutation",
631 describeResult: () => "Operation succeeded",
632 optimistic() {},
633 });
634
635 function Component() {
636 const { run, result, isSuccess } = useMutate(mutation);
637 return (
638 <div>
639 <button onClick={() => run("test")}>Run</button>
640 {isSuccess && <div>Result: {result}</div>}
641 </div>
642 );
643 }
644
645 render(<Component />);
646
647 const button = screen.getByText("Run");
648 await userEvent.click(button);
649
650 await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy());
651
652 // Global success handler should NOT be called
653 expect(successes.length).toBe(0);
654 });
655
656 test("should call global success handler when result is not watched", async () => {
657 const { client, successes } = createTestClient();
658
659 const mutation = client.define({
660 async mutate(value: string) {
661 return `result-${value}`;
662 },
663 describe: "test mutation",
664 describeResult: () => "Operation succeeded",
665 optimistic() {},
666 });
667
668 function Component() {
669 const { run, isPending } = useMutate(mutation);
670 // NOT watching result or isSuccess
671 return (
672 <button onClick={() => run("test")}>
673 {isPending ? "Loading" : "Run"}
674 </button>
675 );
676 }
677
678 render(<Component />);
679
680 const button = screen.getByText("Run");
681 await userEvent.click(button);
682
683 await delay(50);
684
685 // Global success handler SHOULD be called
686 await waitFor(() => expect(successes.length).toBe(1));
687 expect(successes[0]).toBe("Operation succeeded");
688 });
689
690 test("should handle watching isSuccess without result", async () => {
691 const { client, successes } = createTestClient();
692
693 const mutation = client.define({
694 async mutate(value: string) {
695 return `result-${value}`;
696 },
697 describe: "test mutation",
698 describeResult: () => "Operation succeeded",
699 optimistic() {},
700 });
701
702 function Component() {
703 const { run, isSuccess } = useMutate(mutation);
704 // Only watching isSuccess, not result
705 return (
706 <div>
707 <button onClick={() => run("test")}>Run</button>
708 {isSuccess && <div>Success!</div>}
709 </div>
710 );
711 }
712
713 render(<Component />);
714
715 const button = screen.getByText("Run");
716 await userEvent.click(button);
717
718 await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy());
719
720 // Should NOT call global handler since isSuccess is watched
721 expect(successes.length).toBe(0);
722 });
723});
724
725describe("useMutate Hook - clear() Method", () => {
726 test("should clear success state", async () => {
727 const { client } = createTestClient();
728
729 const mutation = client.define({
730 async mutate(value: string) {
731 return `result-${value}`;
732 },
733 describe: "test mutation",
734 optimistic() {},
735 });
736
737 function Component() {
738 const { run, clear, result, isSuccess, status } = useMutate(mutation);
739 return (
740 <div>
741 <button onClick={() => run("test")}>Run</button>
742 <button onClick={clear}>Clear</button>
743 <div>Status: {status}</div>
744 {isSuccess && <div>Result: {result}</div>}
745 </div>
746 );
747 }
748
749 render(<Component />);
750
751 const runButton = screen.getByText("Run");
752 await userEvent.click(runButton);
753
754 await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy());
755 expect(screen.getByText("Status: success")).toBeTruthy();
756
757 const clearButton = screen.getByText("Clear");
758 await userEvent.click(clearButton);
759
760 await waitFor(() => {
761 expect(screen.queryByText("Result: result-test")).toBeNull();
762 expect(screen.getByText("Status: idle")).toBeTruthy();
763 });
764 });
765
766 test("should clear error state", async () => {
767 const { client } = createTestClient();
768
769 const mutation = client.define({
770 async mutate(value: string) {
771 throw new Error("Test error");
772 },
773 describe: "failing mutation",
774 optimistic() {},
775 });
776
777 function Component() {
778 const { run, clear, errorMessage, isError, status } = useMutate(mutation);
779 return (
780 <div>
781 <button onClick={() => run("test")}>Run</button>
782 <button onClick={clear}>Clear</button>
783 <div>Status: {status}</div>
784 {isError && <div data-testid="error-message">Error: {errorMessage}</div>}
785 </div>
786 );
787 }
788
789 const { container } = render(<Component />);
790
791 const runButton = screen.getByText("Run");
792 await userEvent.click(runButton);
793
794 await waitFor(() => {
795 expect(screen.getByTestId("error-message")).toBeTruthy();
796 expect(container.textContent).toContain("Test error");
797 });
798 expect(screen.getByText("Status: error")).toBeTruthy();
799
800 const clearButton = screen.getByText("Clear");
801 await userEvent.click(clearButton);
802
803 await waitFor(() => {
804 expect(screen.queryByTestId("error-message")).toBeNull();
805 expect(screen.getByText("Status: idle")).toBeTruthy();
806 });
807 });
808
809 test("should not affect mutating state when calling clear", async () => {
810 const { client } = createTestClient();
811
812 const mutation = client.define({
813 async mutate(value: string) {
814 await delay(50);
815 return `result-${value}`;
816 },
817 describe: "test mutation",
818 optimistic() {},
819 });
820
821 function Component() {
822 const { run, clear, status, isPending } = useMutate(mutation);
823 return (
824 <div>
825 <button onClick={() => run("test")}>Run</button>
826 <button onClick={clear}>Clear</button>
827 <div>Status: {status}</div>
828 <div>Pending: {isPending.toString()}</div>
829 </div>
830 );
831 }
832
833 render(<Component />);
834
835 const runButton = screen.getByText("Run");
836 await userEvent.click(runButton);
837
838 await waitFor(() => expect(screen.getByText("Status: mutating")).toBeTruthy());
839
840 const clearButton = screen.getByText("Clear");
841 await userEvent.click(clearButton);
842
843 // Status should still be mutating
844 expect(screen.getByText("Status: mutating")).toBeTruthy();
845 expect(screen.getByText("Pending: true")).toBeTruthy();
846 });
847});
848
849describe("useMutate Hook - setError() Method", () => {
850 test("should set error state manually", async () => {
851 const { client } = createTestClient();
852
853 const mutation = client.define({
854 async mutate(value: string) {
855 return value;
856 },
857 describe: "test mutation",
858 optimistic() {},
859 });
860
861 function Component() {
862 const { setError, errorMessage, isError, status } = useMutate(mutation);
863 return (
864 <div>
865 <button onClick={() => setError(new Error("Manual error"))}>
866 Set Error
867 </button>
868 <div>Status: {status}</div>
869 {isError && <div>Error: {errorMessage}</div>}
870 </div>
871 );
872 }
873
874 render(<Component />);
875
876 expect(screen.getByText("Status: idle")).toBeTruthy();
877
878 const button = screen.getByText("Set Error");
879 await userEvent.click(button);
880
881 await waitFor(() => {
882 expect(screen.getByText("Status: error")).toBeTruthy();
883 expect(screen.getByText("Error: Manual error")).toBeTruthy();
884 });
885 });
886
887 test("should clear success state when setting error", async () => {
888 const { client } = createTestClient();
889
890 const mutation = client.define({
891 async mutate(value: string) {
892 return `result-${value}`;
893 },
894 describe: "test mutation",
895 optimistic() {},
896 });
897
898 function Component() {
899 const { run, setError, result, isSuccess, isError, errorMessage } = useMutate(mutation);
900 return (
901 <div>
902 <button onClick={() => run("test")}>Run</button>
903 <button onClick={() => setError(new Error("Manual error"))}>
904 Set Error
905 </button>
906 {isSuccess && <div>Result: {result}</div>}
907 {isError && <div>Error: {errorMessage}</div>}
908 </div>
909 );
910 }
911
912 render(<Component />);
913
914 const runButton = screen.getByText("Run");
915 await userEvent.click(runButton);
916
917 await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy());
918
919 const setErrorButton = screen.getByText("Set Error");
920 await userEvent.click(setErrorButton);
921
922 await waitFor(() => {
923 expect(screen.queryByText("Result: result-test")).toBeNull();
924 expect(screen.getByText("Error: Manual error")).toBeTruthy();
925 });
926 });
927});
928
929describe("useMutate Hook - runWithOptions", () => {
930 test("should support onSuccess callback", async () => {
931 const { client } = createTestClient();
932 const onSuccessSpy = vi.fn();
933
934 const mutation = client.define({
935 async mutate(value: string) {
936 return `result-${value}`;
937 },
938 describe: "test mutation",
939 optimistic() {},
940 });
941
942 function Component() {
943 const { runWithOptions, isSuccess } = useMutate(mutation);
944 return (
945 <div>
946 <button onClick={() => runWithOptions("test", { onSuccess: onSuccessSpy })}>
947 Run
948 </button>
949 {isSuccess && <div>Success!</div>}
950 </div>
951 );
952 }
953
954 render(<Component />);
955
956 const button = screen.getByText("Run");
957 await userEvent.click(button);
958
959 await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy());
960 expect(onSuccessSpy).toHaveBeenCalledWith("result-test");
961 });
962
963 test("should support onError callback", async () => {
964 const { client } = createTestClient();
965 const onErrorSpy = vi.fn();
966
967 const mutation = client.define({
968 async mutate(value: string) {
969 throw new Error("Test error");
970 },
971 describe: "failing mutation",
972 optimistic() {},
973 });
974
975 function Component() {
976 const { runWithOptions, isError } = useMutate(mutation);
977 return (
978 <div>
979 <button onClick={() => runWithOptions("test", { onError: onErrorSpy })}>
980 Run
981 </button>
982 {isError && <div>Error!</div>}
983 </div>
984 );
985 }
986
987 render(<Component />);
988
989 const button = screen.getByText("Run");
990 await userEvent.click(button);
991
992 await waitFor(() => expect(screen.getByText("Error!")).toBeTruthy());
993 expect(onErrorSpy).toHaveBeenCalled();
994 expect(onErrorSpy.mock.calls[0][0]).toBeInstanceOf(Error);
995 });
996
997 test("should support onSettled callback for success", async () => {
998 const { client } = createTestClient();
999 const onSettledSpy = vi.fn();
1000
1001 const mutation = client.define({
1002 async mutate(value: string) {
1003 return `result-${value}`;
1004 },
1005 describe: "test mutation",
1006 optimistic() {},
1007 });
1008
1009 function Component() {
1010 const { runWithOptions } = useMutate(mutation);
1011 return (
1012 <button onClick={() => runWithOptions("test", { onSettled: onSettledSpy })}>
1013 Run
1014 </button>
1015 );
1016 }
1017
1018 render(<Component />);
1019
1020 const button = screen.getByText("Run");
1021 await userEvent.click(button);
1022
1023 await delay(50);
1024
1025 expect(onSettledSpy).toHaveBeenCalled();
1026 });
1027
1028 test("should support onSettled callback for error", async () => {
1029 const { client } = createTestClient();
1030 const onSettledSpy = vi.fn();
1031
1032 const mutation = client.define({
1033 async mutate(value: string) {
1034 throw new Error("Test error");
1035 },
1036 describe: "failing mutation",
1037 optimistic() {},
1038 });
1039
1040 function Component() {
1041 const { runWithOptions } = useMutate(mutation);
1042 return (
1043 <button onClick={() => runWithOptions("test", { onSettled: onSettledSpy })}>
1044 Run
1045 </button>
1046 );
1047 }
1048
1049 render(<Component />);
1050
1051 const button = screen.getByText("Run");
1052 await userEvent.click(button);
1053
1054 await delay(50);
1055
1056 expect(onSettledSpy).toHaveBeenCalled();
1057 });
1058});
1059
1060describe("useMutate Hook - Edge Cases", () => {
1061 test("should handle mutation changing during pending state", async () => {
1062 const { client } = createTestClient();
1063
1064 const mutation1 = client.define({
1065 async mutate(value: string) {
1066 await delay(100);
1067 return `mut1-${value}`;
1068 },
1069 describe: "mutation 1",
1070 optimistic() {},
1071 });
1072
1073 const mutation2 = client.define({
1074 async mutate(value: string) {
1075 await delay(10);
1076 return `mut2-${value}`;
1077 },
1078 describe: "mutation 2",
1079 optimistic() {},
1080 });
1081
1082 function Component({ useMut1 }: { useMut1: boolean }) {
1083 const { run, result, isSuccess, isPending } = useMutate(useMut1 ? mutation1 : mutation2);
1084 return (
1085 <div>
1086 <button onClick={() => run("test")}>Run</button>
1087 <div>Pending: {isPending.toString()}</div>
1088 {isSuccess && <div>Result: {result}</div>}
1089 </div>
1090 );
1091 }
1092
1093 const { rerender } = render(<Component useMut1={true} />);
1094
1095 const button = screen.getByText("Run");
1096 await userEvent.click(button);
1097
1098 await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy());
1099
1100 // Switch mutations while first is pending
1101 rerender(<Component useMut1={false} />);
1102
1103 // State should reset
1104 await waitFor(() => expect(screen.getByText("Pending: false")).toBeTruthy());
1105
1106 // Run mutation2
1107 await userEvent.click(button);
1108
1109 await waitFor(() => expect(screen.getByText("Result: mut2-test")).toBeTruthy());
1110 });
1111
1112 test("should handle multiple sequential calls with same key", async () => {
1113 const { client } = createTestClient();
1114 let callCount = 0;
1115
1116 const mutation = client.define({
1117 async mutate(value: string) {
1118 callCount++;
1119 await delay(10);
1120 return `result-${callCount}`;
1121 },
1122 describe: "test mutation",
1123 optimistic() {},
1124 });
1125
1126 function Component() {
1127 const { run, result, isSuccess } = useMutate(mutation);
1128 return (
1129 <div>
1130 <button onClick={() => run("test")}>Run</button>
1131 {isSuccess && <div>Result: {result}</div>}
1132 </div>
1133 );
1134 }
1135
1136 render(<Component />);
1137
1138 const button = screen.getByText("Run");
1139
1140 // Click multiple times
1141 await userEvent.click(button);
1142 await userEvent.click(button);
1143 await userEvent.click(button);
1144
1145 // Should see the last result
1146 await waitFor(() => expect(screen.getByText(/Result: result-/)).toBeTruthy());
1147 });
1148
1149 test("should handle args property correctly", async () => {
1150 const { client } = createTestClient();
1151
1152 const mutation = client.define({
1153 async mutate(id: string, name: string) {
1154 await delay(10);
1155 return `${id}:${name}`;
1156 },
1157 describe: "test mutation",
1158 optimistic() {},
1159 });
1160
1161 function Component() {
1162 const { run, args, isPending } = useMutate(mutation);
1163 return (
1164 <div>
1165 <button onClick={() => run("123", "test")}>Run</button>
1166 <div>Args: {args ? JSON.stringify(args) : "none"}</div>
1167 <div>Pending: {isPending.toString()}</div>
1168 </div>
1169 );
1170 }
1171
1172 render(<Component />);
1173
1174 expect(screen.getByText("Args: none")).toBeTruthy();
1175
1176 const button = screen.getByText("Run");
1177 await userEvent.click(button);
1178
1179 // Args should be set during mutation
1180 await waitFor(() => {
1181 const argsText = screen.getByText(/Args: \[/);
1182 expect(argsText).toBeTruthy();
1183 });
1184
1185 // Args should be cleared after success
1186 await waitFor(() => expect(screen.getByText("Pending: false")).toBeTruthy());
1187 });
1188});
1189
1190describe("useMutate Hook - Optimistic Data Flag", () => {
1191 test("should set isOptimisticData during mutation states", async () => {
1192 const { client } = createTestClient();
1193
1194 const mutation = client.define({
1195 async mutate(value: string) {
1196 await delay(20);
1197 return `result-${value}`;
1198 },
1199 describe: "test mutation",
1200 optimistic({ helpers }, value: string) {
1201 helpers.setValue("test-key", value);
1202 },
1203 });
1204
1205 function Component() {
1206 const { run, isOptimisticData, status } = useMutate(mutation);
1207 return (
1208 <div>
1209 <button onClick={() => run("test")}>Run</button>
1210 <div>Optimistic: {isOptimisticData.toString()}</div>
1211 <div>Status: {status}</div>
1212 </div>
1213 );
1214 }
1215
1216 render(<Component />);
1217
1218 expect(screen.getByText("Optimistic: false")).toBeTruthy();
1219
1220 const button = screen.getByText("Run");
1221 await userEvent.click(button);
1222
1223 // Should be true during mutation
1224 await waitFor(() => expect(screen.getByText("Optimistic: true")).toBeTruthy());
1225
1226 // Should be false after completion
1227 await waitFor(() => expect(screen.getByText("Status: success")).toBeTruthy());
1228 await waitFor(() => expect(screen.getByText("Optimistic: false")).toBeTruthy());
1229 });
1230});