| 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 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "1.0.0-beta.14", | |
| 3 | "version": "1.0.0-rc.1", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/mod.ts", |
| 6 | 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", |
package-lock.json+29| ... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" |
| 13 | 13 | }, |
| 14 | 14 | "devDependencies": { |
| 15 | "@tanstack/react-query": "^5.90.20", | |
| 15 | 16 | "@testing-library/react": "^16.3.2", |
| 16 | 17 | "@testing-library/user-event": "^14.6.1", |
| 17 | 18 | "@types/node": "^24.10.1", |
| ... | ... | @@ -1431,6 +1432,34 @@ |
| 1431 | 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 | 1463 | "node_modules/@testing-library/dom": { |
| 1435 | 1464 | "version": "10.4.1", |
| 1436 | 1465 | "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", |
package.json+1| ... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" |
| 17 | 17 | }, |
| 18 | 18 | "devDependencies": { |
| 19 | "@tanstack/react-query": "^5.90.20", | |
| 19 | 20 | "@testing-library/react": "^16.3.2", |
| 20 | 21 | "@testing-library/user-event": "^14.6.1", |
| 21 | 22 | "@types/node": "^24.10.1", |
src/mutation.ts+80-30| ... | ... | @@ -63,6 +63,14 @@ export interface MutationOptions< |
| 63 | 63 | * All pending promises resolve with the final result. |
| 64 | 64 | */ |
| 65 | 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 | 75 | * Called before and after optimistic updates to detect no-op mutations. |
| 68 | 76 | * If the snapshots are equal (using deepEquals), the mutation is cancelled. |
| ... | ... | @@ -100,6 +108,15 @@ interface PendingDebouncedState<Args extends unknown[], Result> { |
| 100 | 108 | onSuccess: Array<(result: Result) => void>; |
| 101 | 109 | /** Initial snapshot before any debounced calls (for no-op detection) */ |
| 102 | 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 | 122 | interface Channel<Args extends unknown[], Result, OptimisticHelpers> { |
| ... | ... | @@ -113,6 +130,8 @@ interface Channel<Args extends unknown[], Result, OptimisticHelpers> { |
| 113 | 130 | // Debounce state (only used if debounce option is set) |
| 114 | 131 | debounceTimer: ReturnType<typeof setTimeout> | null; |
| 115 | 132 | pendingDebounced: PendingDebouncedState<Args, Result> | null; |
| 133 | // Track when last debounced mutation executed (for debounceImmediate) | |
| 134 | lastDebouncedExecutionTime: number | null; | |
| 116 | 135 | } |
| 117 | 136 | |
| 118 | 137 | interface Item<Args extends unknown[], Result> { |
| ... | ... | @@ -164,6 +183,7 @@ export class BlockingMutation< |
| 164 | 183 | helpers: null, |
| 165 | 184 | debounceTimer: null, |
| 166 | 185 | pendingDebounced: null, |
| 186 | lastDebouncedExecutionTime: null, | |
| 167 | 187 | }; |
| 168 | 188 | this.#channels.set(key, channel); |
| 169 | 189 | } |
| ... | ... | @@ -185,7 +205,7 @@ export class BlockingMutation< |
| 185 | 205 | result: Result | null = null, |
| 186 | 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 | 209 | channel.listeners.forEach((cb) => cb(event)); |
| 190 | 210 | } |
| 191 | 211 | |
| ... | ... | @@ -247,10 +267,11 @@ export class BlockingMutation< |
| 247 | 267 | const args = array.slice() as Args; |
| 248 | 268 | const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args |
| 249 | 269 | .pop() as RunOptions<Result>; |
| 250 | const suppressGlobalSuccess = onSuccess !== undefined; | |
| 251 | const suppressGlobalError = onError !== undefined; | |
| 270 | const suppressAll = this.#options.debounceMs !== undefined && !onSuccess && !onError; | |
| 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 | 275 | promise.then((result) => { |
| 255 | 276 | // Call user handlers |
| 256 | 277 | onSuccess?.(result); |
| ... | ... | @@ -264,21 +285,27 @@ export class BlockingMutation< |
| 264 | 285 | this.#client.reportSuccess(message); |
| 265 | 286 | } |
| 266 | 287 | } |
| 267 | }).catch((error) => { | |
| 268 | // Call user handlers | |
| 288 | }).catch((caught: unknown) => { | |
| 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 | 295 | onError?.(error); |
| 270 | 296 | onSettled?.({ status: "error", error }); |
| 271 | 297 | |
| 272 | 298 | // Call global handler unless suppressed |
| 273 | 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 | 305 | #runWithOptions( |
| 280 | 306 | args: Args, |
| 281 | { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">, | |
| 307 | userOnRestore: RunOptions<Result>["onRestore"], | |
| 308 | suppressGlobalHandlers: boolean, | |
| 282 | 309 | ): Promise<Result> { |
| 283 | 310 | if (!this.#client.enabled) { |
| 284 | 311 | throw new Error( |
| ... | ... | @@ -290,7 +317,20 @@ export class BlockingMutation< |
| 290 | 317 | |
| 291 | 318 | // Check if debouncing is enabled |
| 292 | 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 | 336 | // Take snapshot before optimistic update (if snapshot function defined) |
| ... | ... | @@ -450,6 +490,10 @@ export class BlockingMutation< |
| 450 | 490 | } |
| 451 | 491 | resolve(result); |
| 452 | 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 | 497 | // if an error happens, then every rollback is called in reverse order |
| 454 | 498 | let next; |
| 455 | 499 | while (next = channel.rollbacks.pop()) next(); |
| ... | ... | @@ -457,7 +501,7 @@ export class BlockingMutation< |
| 457 | 501 | // Cancel all remaining items in the channel |
| 458 | 502 | const remainingItems = channel.queue.splice(0); |
| 459 | 503 | remainingItems.forEach((queuedItem) => { |
| 460 | queuedItem.reject(error); | |
| 504 | queuedItem.reject(wrappedError); | |
| 461 | 505 | }); |
| 462 | 506 | |
| 463 | 507 | // Notify listeners of the error |
| ... | ... | @@ -480,7 +524,7 @@ export class BlockingMutation< |
| 480 | 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 | 532 | args: Args, |
| 489 | 533 | key: string, |
| 490 | 534 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, |
| 491 | userOnRestore?: () => void, | |
| 492 | fromRunWithOptions = false, | |
| 535 | userOnRestore: (() => void) | undefined, | |
| 536 | shouldExecuteImmediate: boolean, | |
| 537 | shouldCallGlobalHandler: boolean, | |
| 493 | 538 | ): Promise<Result> { |
| 494 | 539 | // Capture initial snapshot before first debounced call |
| 495 | 540 | const isFirstDebouncedCall = channel.pendingDebounced === null; |
| ... | ... | @@ -622,6 +667,7 @@ export class BlockingMutation< |
| 622 | 667 | pending: [{ resolve, reject }], |
| 623 | 668 | onSuccess, |
| 624 | 669 | initialSnapshot: isFirstDebouncedCall ? initialSnapshot : undefined, |
| 670 | shouldCallGlobalHandler, | |
| 625 | 671 | }; |
| 626 | 672 | |
| 627 | 673 | // Set status to waiting |
| ... | ... | @@ -633,6 +679,7 @@ export class BlockingMutation< |
| 633 | 679 | channel.pendingDebounced.rollbackCount = rollbacks; |
| 634 | 680 | channel.pendingDebounced.pending.push({ resolve, reject }); |
| 635 | 681 | channel.pendingDebounced.onSuccess = onSuccess; |
| 682 | channel.pendingDebounced.shouldCallGlobalHandler = shouldCallGlobalHandler; | |
| 636 | 683 | // Keep the initial snapshot from the first call |
| 637 | 684 | // Status stays "waiting" |
| 638 | 685 | } |
| ... | ... | @@ -642,10 +689,11 @@ export class BlockingMutation< |
| 642 | 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 | 694 | channel.debounceTimer = setTimeout(() => { |
| 647 | 695 | this.#enqueueDebouncedCall(key, channel); |
| 648 | }, this.#options.debounceMs); | |
| 696 | }, delay); | |
| 649 | 697 | |
| 650 | 698 | return promise; |
| 651 | 699 | } |
| ... | ... | @@ -681,11 +729,13 @@ export class BlockingMutation< |
| 681 | 729 | return; |
| 682 | 730 | } |
| 683 | 731 | |
| 684 | const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced; | |
| 732 | const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; | |
| 685 | 733 | channel.pendingDebounced = null; |
| 686 | 734 | |
| 687 | // Check if there are any listeners at time of enqueue | |
| 688 | const hasListeners = channel.listeners.size > 0; | |
| 735 | // Track execution time for debounceImmediate | |
| 736 | if (this.#options.debounceImmediate) { | |
| 737 | channel.lastDebouncedExecutionTime = Date.now(); | |
| 738 | } | |
| 689 | 739 | |
| 690 | 740 | // Create wrapper resolve/reject that resolves ALL pending promises |
| 691 | 741 | const { |
| ... | ... | @@ -700,27 +750,27 @@ export class BlockingMutation< |
| 700 | 750 | // Resolve all pending promises |
| 701 | 751 | pending.forEach((p) => p.resolve(result)); |
| 702 | 752 | |
| 703 | // Check if there are any listeners at execution time | |
| 704 | const hasListeners = channel.listeners.size > 0; | |
| 705 | if (!hasListeners) { | |
| 753 | // Call global handler if needed (based on whether component is watching success) | |
| 754 | if (shouldCallGlobalHandler) { | |
| 706 | 755 | const message = this.describeResult(args, result); |
| 707 | 756 | if (message && this.#client.reportSuccess) { |
| 708 | 757 | this.#client.reportSuccess(message); |
| 709 | 758 | } |
| 710 | 759 | } |
| 711 | 760 | }, |
| 712 | (error) => { | |
| 713 | // Reject all pending promises | |
| 761 | (caught) => { | |
| 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 | 768 | pending.forEach((p) => p.reject(error)); |
| 715 | 769 | |
| 716 | // Check if there are any listeners at execution time | |
| 717 | const hasListeners = channel.listeners.size > 0; | |
| 718 | if (!hasListeners) { | |
| 770 | // Call global handler if needed (based on whether component is watching errors) | |
| 771 | if (shouldCallGlobalHandler) { | |
| 719 | 772 | this.#client.reportError( |
| 720 | formatFriendlyError( | |
| 721 | this.describe(...args), | |
| 722 | error, | |
| 723 | ), | |
| 773 | formatFriendlyError(description, error), | |
| 724 | 774 | error, |
| 725 | 775 | ); |
| 726 | 776 | } |
src/react.ts+13-5| ... | ... | @@ -150,6 +150,7 @@ class Observer<Args extends unknown[], Result> { |
| 150 | 150 | unsubscribe: (() => void) | null = null; |
| 151 | 151 | currentKey: string | null = null; |
| 152 | 152 | pendingTimer: Timer | null = null; |
| 153 | debounced: boolean; | |
| 153 | 154 | |
| 154 | 155 | constructor(setRerender: (fn: number) => void) { |
| 155 | 156 | this.setRerender = setRerender; |
| ... | ... | @@ -176,6 +177,7 @@ class Observer<Args extends unknown[], Result> { |
| 176 | 177 | this.unsubscribe = null; |
| 177 | 178 | this.currentKey = null; |
| 178 | 179 | this.state = initialState(); |
| 180 | this.debounced = false; | |
| 179 | 181 | } |
| 180 | 182 | |
| 181 | 183 | resetPending() { |
| ... | ... | @@ -203,7 +205,9 @@ class Observer<Args extends unknown[], Result> { |
| 203 | 205 | this.unsubscribe?.(); |
| 204 | 206 | this.unsubscribe = mutation.subscribe( |
| 205 | 207 | mutation.key(args), |
| 206 | ({ status, error, result }) => { | |
| 208 | ({ status, error, result, debounced }) => { | |
| 209 | this.debounced = debounced; | |
| 210 | ||
| 207 | 211 | if (status === "idle") { |
| 208 | 212 | this.setState({ |
| 209 | 213 | isMutating: false, |
| ... | ... | @@ -232,10 +236,11 @@ class Observer<Args extends unknown[], Result> { |
| 232 | 236 | isSuccess: hasResult && !hasError, |
| 233 | 237 | isError: hasError, |
| 234 | 238 | isOptimisticData: status === "waiting" || status === "mutating" |
| 235 | || status === "refetching", | |
| 239 | || status === "refetching" || (hasError && status !== "idle"), | |
| 236 | 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 | 244 | this.pendingTimer = setTimeout(() => { |
| 240 | 245 | this.pendingTimer = null; |
| 241 | 246 | this.setState({ isPending: true }); |
| ... | ... | @@ -258,6 +263,9 @@ class Observer<Args extends unknown[], Result> { |
| 258 | 263 | { |
| 259 | 264 | onSuccess: watchesSuccess ? () => {} : undefined, |
| 260 | 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 | 269 | } satisfies RunOptions<Result>, |
| 262 | 270 | ); |
| 263 | 271 | return promise; |
| ... | ... | @@ -309,7 +317,7 @@ class Observer<Args extends unknown[], Result> { |
| 309 | 317 | isSuccess: hasResult && !hasError, |
| 310 | 318 | isError: hasError, |
| 311 | 319 | isOptimisticData: status === "waiting" || status === "mutating" |
| 312 | || status === "refetching", | |
| 320 | || status === "refetching" || (hasError && status !== "idle"), | |
| 313 | 321 | args: hasError || hasResult ? undefined : this.state.args, |
| 314 | 322 | }); |
| 315 | 323 | }, |
| ... | ... | @@ -368,7 +376,7 @@ class Observer<Args extends unknown[], Result> { |
| 368 | 376 | // TODO: when auth drops this will be dependant on the auth status and isMutating |
| 369 | 377 | get isDisabled() { |
| 370 | 378 | self.watched.add("isMutating"); |
| 371 | return !self.mutation || self.state.isMutating; | |
| 379 | return !self.mutation || (self.state.isMutating && !self.debounced); | |
| 372 | 380 | }, |
| 373 | 381 | get isPending() { |
| 374 | 382 | self.watched.add("isPending"); |
src/types.ts+3| ... | ... | @@ -33,10 +33,13 @@ export interface RunOptions<Result> { |
| 33 | 33 | ) => void; |
| 34 | 34 | /** Called when optimistic state is being restored/rolled back */ |
| 35 | 35 | onRestore?: () => void; |
| 36 | /** @internal Suppresses global handlers for debounced mutations */ | |
| 37 | __suppressGlobalForDebounce?: boolean; | |
| 36 | 38 | } |
| 37 | 39 | |
| 38 | 40 | export interface MutationEvent<Result> { |
| 39 | 41 | status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; |
| 40 | 42 | result: Result | null; |
| 41 | 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 | /* v8 ignore start -- @preserve */ | |
| 1 | 2 | import { MutationClient } from "../src/client.ts"; |
| 2 | 3 | |
| 3 | 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 | }); |