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