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