diff --git a/src/blocking.ts b/src/blocking.ts deleted file mode 100644 index a125e8a6b6e1108d89691b03726a8155d3066eec..0000000000000000000000000000000000000000 --- a/src/blocking.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { message as errMessage } from "@clo/lib/error.ts"; -import type { MutationClient, MutationClientFromConfig } from "./client.ts"; -import type { MutationClientConfig } from "./client.ts"; -import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; - -/** - * Argument to `defineBlocking`. - * @template Args - the parameters to the mutation - * @template Result - the result of the API call - * @template Config - global values and helpers from `MutationContext` - */ -export interface MutationOptions< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> { - /** - * This function is only responsible for performing the underlying API call, - * syncronizing the optimistic state with reality. Throw on failure. A rest - * params type is used to allow type inference. Place this function first to - * ensure TypeScript correctly infers the argument type for the rest of the - * functions. - * - * In practice, optimistic context is never needed in this function, but it - * is provided as the `this` value if you truly desire it. - */ - mutate: (this: Config["context"], ...args: Args) => Promise; - /** - * Used in error messages and debug tools. - * Phrase it considering the template `Failed to ${describe(...)}` - */ - describe: string | ((context: Config["context"] & { args: Args }) => string); - /** - * Used in success messages. - * Phrase it as a complete success message, e.g., "Deleted item successfully" - */ - describeResult: - | string - | ((context: Config["context"] & { args: Args; result: Result }) => string) - | null; - /** - * Specifying the optimistic strategy is required. To disable, pass an empty - * function with a comment to document why it isn't needed. - */ - optimistic: ( - context: OptimisticContext, - ) => void; - /** - * If the optimistic updator function is perfect, then this may be set to false. - * @default true - */ - refetchOnSuccess?: boolean; - /** - * A key to associate related items. For example, returning a user ID. If - * specifying, then all mutations of the same key will evaluate in serial, - * but optimistic updates will apply instantly. - */ - key?: (context: Config["context"] & { args: Args }) => string | string[]; - /** - * Enable debouncing with "last call wins" behavior. When rapid calls arrive, - * the previous optimistic update is rolled back and the new one applied. - * - * All pending promises resolve with the final result. - */ - debounceMs?: number; -} - -export type OptimisticContext< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> = Config["context"] & { - args: Args; - helpers: Config["optimisticHelpers"]; - /** Add an event listener to roll back the update */ - onRestore: (cb: () => void) => void; - /** Add an event listener to apply `Result` to the store. */ - onSuccess: (cb: (result: Result) => void) => void; - /** Add an event listener to refetch data after mutation. */ - onRefetch: (cb: () => Promise) => void; -}; - -interface PendingDebouncedState { - /** Arguments from the most recent call */ - args: Args; - /** Number of rollbacks the most recent call added */ - rollbackCount: number; - /** All pending promises from all superseded calls */ - pending: Array<{ - resolve: (result: Result) => void; - reject: (error: unknown) => void; - }>; - /** Success callbacks from the most recent call */ - onSuccess: Array<(result: Result) => void>; -} - -interface Channel { - listeners: Set<(update: MutationEvent) => void>; - status: "idle" | "waiting" | "mutating" | "refetching"; - rollbacks: Array<() => void>; - refetches: Array<() => Promise>; - queue: Array>; - // Shared optimistic helpers instance for the channel - helpers: OptimisticHelpers | null; - // Debounce state (only used if debounce option is set) - debounceTimer: ReturnType | null; - pendingDebounced: PendingDebouncedState | null; -} - -interface Item { - args: Args; - rollbacks: number; - onSuccess: Array<(result: Result) => void>; - resolve: (result: Result) => void; - reject: (error: unknown) => void; -} - -export class BlockingMutation< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> implements Mutation { - #options: MutationOptions; - #client: MutationClientFromConfig; - #channels: Map< - string, - Channel - > = new Map(); - client: MutationClientFromConfig; - - constructor( - client: MutationClient, - options: MutationOptions, - ) { - this.#options = options; - this.#client = client; - this.client = client; - } - - key(args: Args) { - const k = this.#options.key?.({ ...this.#client.context, args }) - ?? "shared"; - return JSON.stringify(k); - } - - #getOrPutChannel(key: string) { - let channel = this.#channels.get(key); - if (!channel) { - const rollbacks: Array<() => []> = []; - channel = { - listeners: new Set(), - status: "idle", - rollbacks, - refetches: [], - queue: [], - helpers: null, - debounceTimer: null, - pendingDebounced: null, - }; - this.#channels.set(key, channel); - } - return channel; - } - - subscribe( - key: string, - cb: (update: MutationEvent) => void, - ): () => void { - const channel = this.#getOrPutChannel(key); - channel.listeners.add(cb); - return () => channel?.listeners.delete(cb); - } - - #notify( - channel: Channel, - status: MutationEvent["status"], - result: Result | null = null, - error: unknown = null, - ) { - const event: MutationEvent = { status, result, error }; - channel.listeners.forEach((cb) => cb(event)); - } - - #setIdle( - key: string, - channel: Channel, - ) { - // Check if there are pending debounced calls waiting - if (channel.pendingDebounced !== null) { - // Stay in waiting state - channel.status = "waiting"; - this.#notify(channel, "waiting", null, null); - } else { - // Normal idle transition - channel.status = "idle"; - // Discard any unconsumed refetch callbacks - channel.refetches = []; - this.#notify(channel, "idle", null, null); - // Clean up the channel if there are no listeners - if (channel.listeners.size === 0) { - // Clear any pending timers before deleting the channel - if (channel.debounceTimer !== null) { - clearTimeout(channel.debounceTimer); - channel.debounceTimer = null; - } - this.#channels.delete(key); - } - } - } - - describe(...args: Args): string { - const { describe } = this.#options; - return typeof describe === "function" - ? describe({ ...this.#client.context, args }) - : describe; - } - - describeResult(args: Args, result: Result): string | undefined { - const { describeResult } = this.#options; - if (describeResult === null) return undefined; - return typeof describeResult === "function" - ? describeResult({ ...this.#client.context, args, result }) - : describeResult; - } - - /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ - run(...args: Args) { - this.runWithOptions(...args, {}); - } - - /** Calls the mutation with custom handlers that can suppress global handlers. */ - runWithOptions(...array: [...Args, RunOptions]): Promise { - if (!this.#client.enabled) { - throw new Error( - "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", - ); - } - - const args = array.slice() as Args; - const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args - .pop() as RunOptions; - const suppressGlobalSuccess = onSuccess !== undefined; - const suppressGlobalError = onError !== undefined; - - const promise = this.#runAsPromiseWithOptions(args, { onRestore }); - promise.then((result) => { - // Call user handlers - onSuccess?.(result); - onSuccessDataOnly?.(result); - onSettled?.({ status: "success", result }); - - // Call global handler unless suppressed - if (!suppressGlobalSuccess) { - const message = this.describeResult(args, result); - if (message && this.#client.reportSuccess) { - this.#client.reportSuccess(message); - } - } - }).catch((error) => { - // Call user handlers - onError?.(error); - onSettled?.({ status: "error", error }); - - // Call global handler unless suppressed - if (!suppressGlobalError) { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - } - }); - - return promise; - } - - /** Calls the mutation, treating the errors as promise rejection. */ - runAsPromise(...args: Args): Promise { - return this.#runAsPromiseWithOptions(args, {}); - } - - #runAsPromiseWithOptions( - args: Args, - { onRestore: userOnRestore }: Pick, "onRestore">, - ): Promise { - if (!this.#client.enabled) { - throw new Error( - "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", - ); - } - const key = this.key(args); - const channel = this.#getOrPutChannel(key); - - // Check if debouncing is enabled - if (this.#options.debounceMs !== undefined) { - return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true); - } - - // Create shared optimistic helpers instance for the channel if it doesn't exist - if (channel.helpers === null) { - const onRefetch = (cb: () => Promise) => { - channel.refetches.push(cb); - }; - - channel.helpers = this.#client.getOptimisticHelpers({ - onRestore: (cb: () => void) => { - channel.rollbacks.push(cb); - }, - onRefetch, - }); - } - - const onSuccess: Array<(result: Result) => void> = []; - let expired = false; - let rollbacks = 0; - const onRestore = (cb: () => void) => { - if (expired) { - throw new Error( - "Can only call onRestore from within the optimistic update function.", - ); - } - channel.rollbacks.push(cb); - rollbacks += 1; - }; - - // Register user's onRestore callback if provided - if (userOnRestore) { - channel.rollbacks.push(userOnRestore); - rollbacks += 1; - } - - try { - this.#options.optimistic({ - args, - helpers: channel.helpers, - onRestore, - onSuccess(cb) { - if (expired) { - throw new Error( - "Can only call onSuccess from within the optimistic update function.", - ); - } - onSuccess.push(cb); - }, - onRefetch(cb) { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }, - }); - } catch (error) { - expired = true; - let next; - while ( - next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] - ) { - next(); - } - return Promise.reject(error); - } - expired = true; - - const { promise, resolve, reject } = Promise.withResolvers(); - channel.queue.push({ - args, - rollbacks, - onSuccess, - resolve, - reject, - }); - - if (channel.status === "idle") { - this.#executeNext(key, channel); - } - - return promise; - } - - #executeNext( - key: string, - channel: Channel, - ) { - const item = channel.queue.shift(); - if (!item) { - this.#setIdle(key, channel); - return; - } - - const { args, onSuccess, resolve, reject } = item; - channel.status = "mutating"; - this.#notify(channel, "mutating"); - - this.#options.mutate.call(this.#client.context, ...args).then((result) => { - // remove rollbacks and apply optimistic success handlers - channel.rollbacks.splice(0, item.rollbacks); - onSuccess.forEach((cb) => cb(result)); - - if (this.#options.refetchOnSuccess !== false) { - channel.status = "refetching"; - this.#notify(channel, "refetching", result); - // Call refetch and all refetch callbacks in parallel - const refetchCallbacks = channel.refetches.splice(0); - Promise.allSettled(refetchCallbacks.map((cb) => cb())).then( - (results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }, - ).finally(() => { - this.#executeNext(key, channel); - }); - } else { - // Discard refetch callbacks if refetchOnSuccess is false - channel.refetches = []; - this.#executeNext(key, channel); - } - resolve(result); - }, (error) => { - // if an error happens, then every rollback is called in reverse order - let next; - while (next = channel.rollbacks.pop()) next(); - - // Cancel all remaining items in the channel - const remainingItems = channel.queue.splice(0); - remainingItems.forEach((queuedItem) => { - queuedItem.reject(error); - }); - - // Notify listeners of the error - this.#notify(channel, "mutating", null, error); - - // Refetch to restore correct state - channel.status = "refetching"; - this.#notify(channel, "refetching", null, error); - // Call refetch and all refetch callbacks in parallel - const refetchCallbacks = channel.refetches.splice(0); - Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - this.#setIdle(key, channel); - }); - - reject(error); - }); - } - - #runDebouncedAndReturn( - args: Args, - key: string, - channel: Channel, - userOnRestore?: () => void, - fromRunWithOptions = false, - ): Promise { - // If there's a pending debounced call, roll it back - if (channel.pendingDebounced) { - this.#rollbackPendingDebounced(channel); - } - - // Create shared helpers if needed (same as current implementation) - if (channel.helpers === null) { - const onRefetch = (cb: () => Promise) => { - channel.refetches.push(cb); - }; - channel.helpers = this.#client.getOptimisticHelpers({ - onRestore: (cb: () => void) => { - channel.rollbacks.push(cb); - }, - onRefetch, - }); - } - - // Apply optimistic update (same logic as current runAndReturn) - const onSuccess: Array<(result: Result) => void> = []; - let expired = false; - let rollbacks = 0; - const onRestore = (cb: () => void) => { - if (expired) { - throw new Error( - "Can only call onRestore from within the optimistic update function.", - ); - } - channel.rollbacks.push(cb); - rollbacks += 1; - }; - - // Register user's onRestore callback if provided - if (userOnRestore) { - channel.rollbacks.push(userOnRestore); - rollbacks += 1; - } - - try { - this.#options.optimistic({ - args, - helpers: channel.helpers, - onRestore, - onSuccess(cb) { - if (expired) { - throw new Error( - "Can only call onSuccess from within the optimistic update function.", - ); - } - onSuccess.push(cb); - }, - onRefetch(cb) { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }, - }); - } catch (error) { - expired = true; - // Roll back the rollbacks we just added - let next; - while ( - next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] - ) { - next(); - } - return Promise.reject(error); - } - expired = true; - - // Create promise for this call - const { promise, resolve, reject } = Promise.withResolvers(); - - // Store or update pending debounced state - if (channel.pendingDebounced === null) { - // First debounced call - channel.pendingDebounced = { - args, - rollbackCount: rollbacks, - pending: [{ resolve, reject }], - onSuccess, - }; - - // Set status to waiting - channel.status = "waiting"; - this.#notify(channel, "waiting"); - } else { - // Subsequent debounced call - update state - channel.pendingDebounced.args = args; - channel.pendingDebounced.rollbackCount = rollbacks; - channel.pendingDebounced.pending.push({ resolve, reject }); - channel.pendingDebounced.onSuccess = onSuccess; - // Status stays "waiting" - } - - // Clear existing timer - if (channel.debounceTimer !== null) { - clearTimeout(channel.debounceTimer); - } - - // Start new timer - channel.debounceTimer = setTimeout(() => { - this.#enqueueDebouncedCall(key, channel); - }, this.#options.debounceMs); - - return promise; - } - - #rollbackPendingDebounced( - channel: Channel, - ) { - if (!channel.pendingDebounced) return; - - const { rollbackCount } = channel.pendingDebounced; - - // Roll back this call's optimistic updates (in reverse order) - // Remove from the end of the rollbacks array - for (let i = 0; i < rollbackCount; i++) { - const rollback = channel.rollbacks.pop(); - if (rollback) rollback(); - } - - // Note: We do NOT reject the promises here - // They will all resolve when the final call completes - } - - #enqueueDebouncedCall( - key: string, - channel: Channel, - ) { - // Clear timer - channel.debounceTimer = null; - - // Safety check - if (!channel.pendingDebounced) { - this.#setIdle(key, channel); - return; - } - - const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced; - channel.pendingDebounced = null; - - // Check if there are any listeners at time of enqueue - const hasListeners = channel.listeners.size > 0; - - // Create wrapper resolve/reject that resolves ALL pending promises - const { - promise: wrapperPromise, - resolve: wrapperResolve, - reject: wrapperReject, - } = Promise.withResolvers(); - - // Resolve/reject pending promises and add global handler logic for execution-time checks - wrapperPromise.then( - (result) => { - // Resolve all pending promises - pending.forEach((p) => p.resolve(result)); - - // Check if there are any listeners at execution time - const hasListeners = channel.listeners.size > 0; - if (!hasListeners) { - const message = this.describeResult(args, result); - if (message && this.#client.reportSuccess) { - this.#client.reportSuccess(message); - } - } - }, - (error) => { - // Reject all pending promises - pending.forEach((p) => p.reject(error)); - - // Check if there are any listeners at execution time - const hasListeners = channel.listeners.size > 0; - if (!hasListeners) { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - } - }, - ); - - // Add to queue (same structure as regular blocking mutation) - channel.queue.push({ - args, - rollbacks: rollbackCount, - onSuccess, - resolve: wrapperResolve, - reject: wrapperReject, - }); - - // If queue was idle/waiting, start execution - if (channel.status === "idle" || channel.status === "waiting") { - this.#executeNext(key, channel); - } - // Otherwise, it will execute when the current item finishes - } -} diff --git a/src/client.ts b/src/client.ts index 968f7a26909956b60736fdd0666b79bd9607b60d..5f4fa44b1fe220a857fd35d628323fde7212ec7c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ -import { BlockingMutation, type MutationOptions } from "./blocking.ts"; import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; +import { BlockingMutation, type MutationOptions } from "./mutation.ts"; import type { Mutation } from "./types.ts"; export interface MutationClientConfig { @@ -79,29 +79,4 @@ export class MutationClient< { context: Context; optimisticHelpers: OptimisticHelpers } >(this, options); } - - /** - * **This API is experimental and subject to alteration or removal.** - * - * Define a batched mutation. Each call to the mutation applies new optimistic - * state, and after a debounce or throttle, the new optimistic state is - * committed to the API. UI never shows a pending state for debounced mutations. This - * works great for auto-saving input fields, follow buttons, and is preferred - * whenever possible. - */ - defineBatched( - options: DebouncedMutationOptions< - Args, - Result, - Optimistic, - { context: Context; optimisticHelpers: OptimisticHelpers } - >, - ): Mutation { - return new DebouncedMutation< - Args, - Result, - Optimistic, - { context: Context; optimisticHelpers: OptimisticHelpers } - >(this, options); - } } diff --git a/src/debounced.ts b/src/debounced.ts deleted file mode 100644 index e2dfd4fa493d646607afe7bbf2934ef76740773d..0000000000000000000000000000000000000000 --- a/src/debounced.ts +++ /dev/null @@ -1,609 +0,0 @@ -import { message as errMessage } from "@clo/lib/error.ts"; -import type { MutationClient, MutationClientFromConfig } from "./client.ts"; -import type { MutationClientConfig } from "./client.ts"; -import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; - -export interface DebouncedMutationOptions< - Args extends unknown[], - Result, - Optimistic, - Config extends MutationClientConfig, -> { - /** - * Update the UI for one call to the optimistic function. - * A rest params type is used to allow type inference. Place this function first to - * ensure TypeScript correctly infers the argument type for the rest of the functions. - */ - optimistic: ( - context: DebouncedOptimisticContext, - ...args: Args - ) => void; - /** - * Retrieve the current/optimistic value of the mutation. When this returns - * the same thing as when the mutation started, it means that `mutate` does - * not need to be called since the data is the same. - * - * Don't snapshot unrelated state that this mutation isn't concerned with. - */ - getValue: (context: Config["context"] & { args: Args }) => Optimistic; - - /** - * @default "debounce" - */ - mode?: "debounce" | "throttle"; - /** - * Milliseconds - * @default 200 - */ - time?: number; - - /** A key to associate debounced items. For example, returning a user ID */ - key: ( - context: Config["context"] & { args: NoInfer }, - ) => string | string[]; - - /** - * Commit the optimistic state. Throw on failure. - */ - commit: ( - context: DebouncedCommitContext, Optimistic, Config>, - ) => Promise; - /** - * Used in error messages and debug tools. - * "Failed to {action}" - */ - describe: - | string - | (( - context: DebouncedCommitContext, Optimistic, Config>, - ) => string); - /** - * Used in success messages. - * Phrase it as a complete success message: "Renamed item successfully" - */ - describeResult: - | string - | (( - context: DebouncedCommitContext, Optimistic, Config> & { - result: Result; - }, - ) => string) - | null; - /** - * Refetch all of the data this mutation could have affected. - */ - refetch?: ( - context: Config["context"] & { args: NoInfer }, - ) => Promise; - - refetchOnSuccess?: boolean; -} - -export type DebouncedOptimisticContext = - & Config["context"] - & { - /** Add an event listener to roll back the update */ - onRestore: (cb: () => void) => void; - helpers: Config["optimisticHelpers"]; - }; - -export type DebouncedCommitContext< - Args, - Optimistic, - Config extends MutationClientConfig, -> = Config["context"] & { - /** One of the arguments. Use this only to extract the shared key */ - args: Args; - /** The initial snapshot */ - initial: Optimistic; - /** The compared snapshot */ - current: Optimistic; -}; - -interface DebouncedChannel< - Args extends unknown[], - Result, - Optimistic, - OptimisticHelpers, -> { - listeners: Set<(update: MutationEvent) => void>; - status: "idle" | "waiting" | "mutating" | "refetching"; - - // Snapshot before first call in current debounced run - initial: Optimistic | null; - // First args in debounced run (used for commit/describe/getValue) - firstArgs: Args | null; - rollbacks: Array<() => void>; - refetches: Array<() => Promise>; - timer: ReturnType | null; - - // Track last commit time for throttle mode - lastCommitTime: number; - - // Shared optimistic helpers instance for the current debounced run - helpers: OptimisticHelpers | null; - - // Pending promises from callers in current debounced run - pending: Array<{ - args: Args; - resolve: (result: Result) => void; - reject: (error: unknown) => void; - reportSuccessGlobally?: boolean; - }>; -} - -export class DebouncedMutation< - Args extends unknown[], - Result, - Optimistic, - Config extends MutationClientConfig, -> implements Mutation { - #options: DebouncedMutationOptions; - #client: MutationClientFromConfig; - #channels: Map< - string, - DebouncedChannel - > = new Map(); - client: MutationClientFromConfig; - - constructor( - client: MutationClient, - options: DebouncedMutationOptions, - ) { - this.#options = options; - this.#client = client; - this.client = client; - } - - key(args: Args): string { - const k = this.#options.key({ ...this.#client.context, args }); - return JSON.stringify(k); - } - - #getOrPutChannel( - key: string, - ): DebouncedChannel { - let channel = this.#channels.get(key); - if (!channel) { - channel = { - listeners: new Set(), - status: "idle", - initial: null, - firstArgs: null, - rollbacks: [], - refetches: [], - timer: null, - lastCommitTime: 0, - helpers: null, - pending: [], - }; - this.#channels.set(key, channel); - } - return channel; - } - - subscribe( - key: string, - cb: (update: MutationEvent) => void, - ): () => void { - const channel = this.#getOrPutChannel(key); - channel.listeners.add(cb); - return () => channel?.listeners.delete(cb); - } - - #notify( - channel: DebouncedChannel< - Args, - Result, - Optimistic, - Config["optimisticHelpers"] - >, - status: MutationEvent["status"], - result: Result | null = null, - error: unknown = null, - ) { - const event: MutationEvent = { status, result, error }; - channel.listeners.forEach((cb) => cb(event)); - } - - #setIdle( - key: string, - channel: DebouncedChannel< - Args, - Result, - Optimistic, - Config["optimisticHelpers"] - >, - ) { - channel.status = "idle"; - this.#notify(channel, "idle", null, null); - // Clean up the channel if there are no listeners - if (channel.listeners.size === 0) { - this.#channels.delete(key); - } - } - - #resetDebouncedState( - channel: DebouncedChannel< - Args, - Result, - Optimistic, - Config["optimisticHelpers"] - >, - ) { - channel.initial = null; - channel.firstArgs = null; - channel.rollbacks = []; - channel.refetches = []; - channel.helpers = null; - channel.pending = []; - if (channel.timer !== null) { - clearTimeout(channel.timer); - channel.timer = null; - } - } - - describe(...args: Args): string { - const { describe } = this.#options; - if (typeof describe === "function") { - // For describe, we need initial/current but may not have them yet - // Use placeholder values when called outside of commit context - return describe({ - ...this.#client.context, - args, - initial: null as unknown as Optimistic, - current: null as unknown as Optimistic, - }); - } - return describe; - } - - // Not available for debounced mutations - success reporting happens during commit - describeResult: null = null; - - #describeResult( - args: Args, - initial: Optimistic, - current: Optimistic, - result: Result, - ): string | undefined { - const { describeResult } = this.#options; - if (describeResult === null) return undefined; - return typeof describeResult === "function" - ? describeResult({ - ...this.#client.context, - args, - initial, - current, - result, - }) - : describeResult; - } - - /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ - run(...args: Args): void { - if (!this.#client.enabled) { - throw new Error( - "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", - ); - } - this.#runAndReturn(args, true, undefined).catch((error) => { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - }); - } - - runWithOptions(...array: [...args: Args, options: RunOptions]): void { - if (!this.#client.enabled) { - throw new Error( - "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", - ); - } - const args = array.slice() as Args; - const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args - .pop() as RunOptions; - const suppressGlobalSuccess = onSuccess !== undefined; - const suppressGlobalError = onError !== undefined; - - const promise = this.#runAndReturn(args, !suppressGlobalSuccess, onRestore); - - promise.then((result) => { - // Call user handlers - onSuccess?.(result); - onSuccessDataOnly?.(result); - onSettled?.({ status: "success", result }); - }).catch((error) => { - // Call user handlers - onError?.(error); - onSettled?.({ status: "error", error }); - - // Call global error handler unless suppressed - if (!suppressGlobalError) { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - } - }); - } - - /** Calls the mutation, treating the errors as promise rejection. */ - runAsPromise(...args: Args): Promise { - if (!this.#client.enabled) { - throw new Error( - "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", - ); - } - return this.#runAndReturn(args, false, undefined); - } - - #runAndReturn( - args: Args, - reportSuccessGlobally: boolean, - userOnRestore?: () => void, - ): Promise { - const key = this.key(args); - const channel = this.#getOrPutChannel(key); - - // If this is the first call in the debounced run, take a snapshot and create shared helpers - if (channel.initial === null) { - channel.initial = this.#options.getValue({ - ...this.#client.context, - args, - }); - channel.firstArgs = args; - - // Create shared onRefetch handler for the debounced run - const onRefetch = (cb: () => Promise) => { - channel.refetches.push(cb); - }; - - // Create shared optimistic helpers instance for this debounced run - channel.helpers = this.#client.getOptimisticHelpers({ - onRestore: (cb: () => void) => { - channel.rollbacks.push(cb); - }, - onRefetch, - }); - } - - // Apply optimistic update - let expired = false; - const onRestore = (cb: () => void) => { - if (expired) { - throw new Error( - "Can only call onRestore from within the optimistic update function.", - ); - } - channel.rollbacks.push(cb); - }; - - // Register user's onRestore callback if provided - if (userOnRestore) { - channel.rollbacks.push(userOnRestore); - } - - try { - this.#options.optimistic( - { - ...this.#client.context, - onRestore, - helpers: channel.helpers!, - }, - ...args, - ); - } catch (error) { - expired = true; - // We don't know how many were added, so we can't do partial rollback easily - // For simplicity, rollback everything and reject - let next; - while ((next = channel.rollbacks.pop())) next(); - this.#resetDebouncedState(channel); - return Promise.reject(error); - } - expired = true; - - // Create promise for this caller - const { promise, resolve, reject } = Promise.withResolvers(); - channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); - - // Set status to waiting and notify - if (channel.status === "idle") { - channel.status = "waiting"; - this.#notify(channel, "waiting"); - } - - // Schedule commit based on mode - this.#scheduleCommit(key, channel); - - return promise; - } - - #scheduleCommit( - key: string, - channel: DebouncedChannel< - Args, - Result, - Optimistic, - Config["optimisticHelpers"] - >, - ) { - const time = this.#options.time ?? 200; - - if (this.#options.mode !== "throttle") { - // Debounce: reset timer on each call - if (channel.timer !== null) { - clearTimeout(channel.timer); - } - channel.timer = setTimeout(() => this.#commit(key, channel), time); - } else { - // Throttle: commit immediately if enough time passed, otherwise wait - // Use status to track if a commit is in progress - if (channel.timer === null && channel.status === "waiting") { - const elapsed = Date.now() - channel.lastCommitTime; - if (elapsed >= time) { - // Enough time has passed, commit immediately - this.#commit(key, channel); - } else { - // Wait for remaining time - channel.timer = setTimeout( - () => this.#commit(key, channel), - time - elapsed, - ); - } - } - // If timer exists or commit is in progress, do nothing - will commit when ready - } - } - - #commit( - key: string, - channel: DebouncedChannel< - Args, - Result, - Optimistic, - Config["optimisticHelpers"] - >, - ) { - // Clear timer - if (channel.timer !== null) { - clearTimeout(channel.timer); - channel.timer = null; - } - - // Safety check - if (channel.firstArgs === null || channel.initial === null) { - this.#setIdle(key, channel); - return; - } - - const firstArgs = channel.firstArgs; - const initial = channel.initial; - const pendingItems = [...channel.pending]; - const rollbacks = [...channel.rollbacks]; - const refetchCallbacks = [...channel.refetches]; - - // Get current snapshot - const current = this.#options.getValue({ - ...this.#client.context, - args: firstArgs, - }); - - // Check if anything changed - if (this.#client.deepEquals(initial, current)) { - // No change - resolve all pending with a null result and reset - pendingItems.forEach(({ resolve }) => resolve(null as Result)); - this.#resetDebouncedState(channel); - this.#setIdle(key, channel); - return; - } - - // Set status to mutating - channel.status = "mutating"; - this.#notify(channel, "mutating"); - - // Clear debounced state before async operation (but keep rollbacks/refetches for error case) - channel.initial = null; - channel.firstArgs = null; - channel.pending = []; - channel.rollbacks = []; - channel.refetches = []; - - // Call commit - this.#options - .commit({ - ...this.#client.context, - args: firstArgs, - initial, - current, - }) - .then((result) => { - // Success - rollbacks are discarded (optimistic was correct) - // Resolve all pending promises - pendingItems.forEach(({ resolve }) => resolve(result)); - - // Report success globally if any of the pending items requested it - const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); - if (shouldReportSuccess) { - const message = this.#describeResult( - firstArgs, - initial, - current, - result, - ); - if (message && this.#client.reportSuccess) { - this.#client.reportSuccess(message); - } - } - - // Record commit time for throttle mode - channel.lastCommitTime = Date.now(); - - // Refetch - channel.status = "refetching"; - this.#notify(channel, "refetching", result); - // Call refetch and all refetch callbacks in parallel - Promise.allSettled([ - this.#options.refetch?.({ - ...this.#client.context, - args: firstArgs, - }), - ...refetchCallbacks.map((cb) => cb()), - ]).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - // Check if new calls came in during the commit - if (channel.pending.length > 0) { - // There are pending calls that need to be committed - channel.status = "waiting"; - this.#notify(channel, "waiting"); - this.#scheduleCommit(key, channel); - } else { - this.#setIdle(key, channel); - } - }); - }) - .catch((error) => { - // Error - call all rollbacks in reverse order - let next; - const rollbacksCopy = [...rollbacks]; - while ((next = rollbacksCopy.pop())) next(); - - // Reject all pending promises - pendingItems.forEach(({ reject }) => reject(error)); - - // Notify listeners of the error - this.#notify(channel, "mutating", null, error); - - // Refetch to restore correct state - channel.status = "refetching"; - this.#notify(channel, "refetching", null, error); - // Call refetch and all refetch callbacks in parallel - Promise.allSettled(refetchCallbacks.map((cb) => cb())).then( - (results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }, - ).finally(() => { - // Check if new calls came in during the commit - if (channel.pending.length > 0) { - // There are pending calls that need to be committed - channel.status = "waiting"; - this.#notify(channel, "waiting"); - this.#scheduleCommit(key, channel); - } else { - this.#setIdle(key, channel); - } - }); - }); - } -} diff --git a/src/mod.ts b/src/mod.ts index 8ab5314ace755c914048145be1b57cbab8cb8f2a..2e81282cdee0d6f63e3ad3efc83b7541dfb8e445 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,11 +1,10 @@ -export type { MutationOptions, OptimisticContext } from "./blocking.ts"; export { MutationClient, type MutationClientConfig, type MutationClientFromConfig, type MutationClientOptions, } from "./client.ts"; -export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts"; +export type { MutationOptions, OptimisticContext } from "./mutation.ts"; export { createMutationButton, type MutationButtonComponent, diff --git a/src/mutation.ts b/src/mutation.ts new file mode 100644 index 0000000000000000000000000000000000000000..80d3c62dbfc5e977b29a198135178601e8ef289e --- /dev/null +++ b/src/mutation.ts @@ -0,0 +1,662 @@ +import { message as errMessage } from "@clo/lib/error.ts"; +import type { MutationClient, MutationClientFromConfig } from "./client.ts"; +import type { MutationClientConfig } from "./client.ts"; +import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; + +/** + * Argument to `defineBlocking`. + * @template Args - the parameters to the mutation + * @template Result - the result of the API call + * @template Config - global values and helpers from `MutationContext` + */ +export interface MutationOptions< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> { + /** + * This function is only responsible for performing the underlying API call, + * syncronizing the optimistic state with reality. Throw on failure. A rest + * params type is used to allow type inference. Place this function first to + * ensure TypeScript correctly infers the argument type for the rest of the + * functions. + * + * In practice, optimistic context is never needed in this function, but it + * is provided as the `this` value if you truly desire it. + */ + mutate: (this: Config["context"], ...args: Args) => Promise; + /** + * Used in error messages and debug tools. + * Phrase it considering the template `Failed to ${describe(...)}` + */ + describe: string | ((context: Config["context"] & { args: Args }) => string); + /** + * Used in success messages. + * Phrase it as a complete success message: "Deleted Item" + */ + describeResult: + | string + | ((context: Config["context"] & { args: Args; result: Result }) => string) + | null; + /** + * Specifying the optimistic strategy is required. To disable, pass an empty + * function with a comment to document why it isn't needed. + */ + optimistic: ( + context: OptimisticContext, + ) => void; + /** + * If the optimistic updator function is perfect, then this may be set to false. + * @default true + */ + refetchOnSuccess?: boolean; + /** + * A key to associate related items. For example, returning a user ID. If + * specifying, then all mutations of the same key will evaluate in serial, + * but optimistic updates will apply instantly. + */ + key?: (context: Config["context"] & { args: Args }) => string | string[]; + /** + * Enable debouncing with "last call wins" behavior. When rapid calls arrive, + * the previous optimistic update is rolled back and the new one applied. + * + * All pending promises resolve with the final result. + */ + debounceMs?: number; +} + +export type OptimisticContext< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> = Config["context"] & { + args: Args; + helpers: Config["optimisticHelpers"]; + /** Add an event listener to roll back the update */ + onRestore: (cb: () => void) => void; + /** Add an event listener to apply `Result` to the store. */ + onSuccess: (cb: (result: Result) => void) => void; + /** Add an event listener to refetch data after mutation. */ + onRefetch: (cb: () => Promise) => void; +}; + +interface PendingDebouncedState { + /** Arguments from the most recent call */ + args: Args; + /** Number of rollbacks the most recent call added */ + rollbackCount: number; + /** All pending promises from all superseded calls */ + pending: Array<{ + resolve: (result: Result) => void; + reject: (error: unknown) => void; + }>; + /** Success callbacks from the most recent call */ + onSuccess: Array<(result: Result) => void>; +} + +interface Channel { + listeners: Set<(update: MutationEvent) => void>; + status: "idle" | "waiting" | "mutating" | "refetching"; + rollbacks: Array<() => void>; + refetches: Array<() => Promise>; + queue: Array>; + // Shared optimistic helpers instance for the channel + helpers: OptimisticHelpers | null; + // Debounce state (only used if debounce option is set) + debounceTimer: ReturnType | null; + pendingDebounced: PendingDebouncedState | null; +} + +interface Item { + args: Args; + rollbacks: number; + onSuccess: Array<(result: Result) => void>; + resolve: (result: Result) => void; + reject: (error: unknown) => void; +} + +export class BlockingMutation< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> implements Mutation { + #options: MutationOptions; + #client: MutationClientFromConfig; + #channels: Map< + string, + Channel + > = new Map(); + client: MutationClientFromConfig; + + constructor( + client: MutationClient, + options: MutationOptions, + ) { + this.#options = options; + this.#client = client; + this.client = client; + } + + key(args: Args) { + const k = this.#options.key?.({ ...this.#client.context, args }) + ?? "shared"; + return JSON.stringify(k); + } + + #getOrPutChannel(key: string) { + let channel = this.#channels.get(key); + if (!channel) { + const rollbacks: Array<() => []> = []; + channel = { + listeners: new Set(), + status: "idle", + rollbacks, + refetches: [], + queue: [], + helpers: null, + debounceTimer: null, + pendingDebounced: null, + }; + this.#channels.set(key, channel); + } + return channel; + } + + subscribe( + key: string, + cb: (update: MutationEvent) => void, + ): () => void { + const channel = this.#getOrPutChannel(key); + channel.listeners.add(cb); + return () => channel?.listeners.delete(cb); + } + + #notify( + channel: Channel, + status: MutationEvent["status"], + result: Result | null = null, + error: unknown = null, + ) { + const event: MutationEvent = { status, result, error }; + channel.listeners.forEach((cb) => cb(event)); + } + + #setIdle( + key: string, + channel: Channel, + ) { + // Check if there are pending debounced calls waiting + if (channel.pendingDebounced !== null) { + // Stay in waiting state + channel.status = "waiting"; + this.#notify(channel, "waiting", null, null); + } else { + // Normal idle transition + channel.status = "idle"; + // Discard any unconsumed refetch callbacks + channel.refetches = []; + this.#notify(channel, "idle", null, null); + // Clean up the channel if there are no listeners + if (channel.listeners.size === 0) { + // Clear any pending timers before deleting the channel + if (channel.debounceTimer !== null) { + clearTimeout(channel.debounceTimer); + channel.debounceTimer = null; + } + this.#channels.delete(key); + } + } + } + + describe(...args: Args): string { + const { describe } = this.#options; + return typeof describe === "function" + ? describe({ ...this.#client.context, args }) + : describe; + } + + describeResult(args: Args, result: Result): string | undefined { + const { describeResult } = this.#options; + if (describeResult === null) return undefined; + return typeof describeResult === "function" + ? describeResult({ ...this.#client.context, args, result }) + : describeResult; + } + + /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ + run(...args: Args) { + this.runWithOptions(...args, {}); + } + + /** Calls the mutation with custom handlers that can suppress global handlers. */ + runWithOptions(...array: [...Args, RunOptions]): Promise { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + + const args = array.slice() as Args; + const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args + .pop() as RunOptions; + const suppressGlobalSuccess = onSuccess !== undefined; + const suppressGlobalError = onError !== undefined; + + const promise = this.#runAsPromiseWithOptions(args, { onRestore }); + promise.then((result) => { + // Call user handlers + onSuccess?.(result); + onSuccessDataOnly?.(result); + onSettled?.({ status: "success", result }); + + // Call global handler unless suppressed + if (!suppressGlobalSuccess) { + const message = this.describeResult(args, result); + if (message && this.#client.reportSuccess) { + this.#client.reportSuccess(message); + } + } + }).catch((error) => { + // Call user handlers + onError?.(error); + onSettled?.({ status: "error", error }); + + // Call global handler unless suppressed + if (!suppressGlobalError) { + const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + this.#client.reportError(message, error); + } + }); + + return promise; + } + + /** Calls the mutation, treating the errors as promise rejection. */ + runAsPromise(...args: Args): Promise { + return this.#runAsPromiseWithOptions(args, {}); + } + + #runAsPromiseWithOptions( + args: Args, + { onRestore: userOnRestore }: Pick, "onRestore">, + ): Promise { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + const key = this.key(args); + const channel = this.#getOrPutChannel(key); + + // Check if debouncing is enabled + if (this.#options.debounceMs !== undefined) { + return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true); + } + + // Create shared optimistic helpers instance for the channel if it doesn't exist + if (channel.helpers === null) { + const onRefetch = (cb: () => Promise) => { + channel.refetches.push(cb); + }; + + channel.helpers = this.#client.getOptimisticHelpers({ + onRestore: (cb: () => void) => { + channel.rollbacks.push(cb); + }, + onRefetch, + }); + } + + const onSuccess: Array<(result: Result) => void> = []; + let expired = false; + let rollbacks = 0; + const onRestore = (cb: () => void) => { + if (expired) { + throw new Error( + "Can only call onRestore from within the optimistic update function.", + ); + } + channel.rollbacks.push(cb); + rollbacks += 1; + }; + + // Register user's onRestore callback if provided + if (userOnRestore) { + channel.rollbacks.push(userOnRestore); + rollbacks += 1; + } + + try { + this.#options.optimistic({ + args, + helpers: channel.helpers, + onRestore, + onSuccess(cb) { + if (expired) { + throw new Error( + "Can only call onSuccess from within the optimistic update function.", + ); + } + onSuccess.push(cb); + }, + onRefetch(cb) { + if (expired) { + throw new Error( + "Can only call onRefetch from within the optimistic update function.", + ); + } + channel.refetches.push(cb); + }, + }); + } catch (error) { + expired = true; + let next; + while ( + next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] + ) { + next(); + } + return Promise.reject(error); + } + expired = true; + + const { promise, resolve, reject } = Promise.withResolvers(); + channel.queue.push({ + args, + rollbacks, + onSuccess, + resolve, + reject, + }); + + if (channel.status === "idle") { + this.#executeNext(key, channel); + } + + return promise; + } + + #executeNext( + key: string, + channel: Channel, + ) { + const item = channel.queue.shift(); + if (!item) { + this.#setIdle(key, channel); + return; + } + + const { args, onSuccess, resolve, reject } = item; + channel.status = "mutating"; + this.#notify(channel, "mutating"); + + this.#options.mutate.call(this.#client.context, ...args).then((result) => { + // remove rollbacks and apply optimistic success handlers + channel.rollbacks.splice(0, item.rollbacks); + onSuccess.forEach((cb) => cb(result)); + + if (this.#options.refetchOnSuccess !== false) { + channel.status = "refetching"; + this.#notify(channel, "refetching", result); + // Call refetch and all refetch callbacks in parallel + const refetchCallbacks = channel.refetches.splice(0); + Promise.allSettled(refetchCallbacks.map((cb) => cb())).then( + (results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }, + ).finally(() => { + this.#executeNext(key, channel); + }); + } else { + // Discard refetch callbacks if refetchOnSuccess is false + channel.refetches = []; + this.#executeNext(key, channel); + } + resolve(result); + }, (error) => { + // if an error happens, then every rollback is called in reverse order + let next; + while (next = channel.rollbacks.pop()) next(); + + // Cancel all remaining items in the channel + const remainingItems = channel.queue.splice(0); + remainingItems.forEach((queuedItem) => { + queuedItem.reject(error); + }); + + // Notify listeners of the error + this.#notify(channel, "mutating", null, error); + + // Refetch to restore correct state + channel.status = "refetching"; + this.#notify(channel, "refetching", null, error); + // Call refetch and all refetch callbacks in parallel + const refetchCallbacks = channel.refetches.splice(0); + Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }).finally(() => { + this.#setIdle(key, channel); + }); + + reject(error); + }); + } + + #runDebouncedAndReturn( + args: Args, + key: string, + channel: Channel, + userOnRestore?: () => void, + fromRunWithOptions = false, + ): Promise { + // If there's a pending debounced call, roll it back + if (channel.pendingDebounced) { + this.#rollbackPendingDebounced(channel); + } + + // Create shared helpers if needed (same as current implementation) + if (channel.helpers === null) { + const onRefetch = (cb: () => Promise) => { + channel.refetches.push(cb); + }; + channel.helpers = this.#client.getOptimisticHelpers({ + onRestore: (cb: () => void) => { + channel.rollbacks.push(cb); + }, + onRefetch, + }); + } + + // Apply optimistic update (same logic as current runAndReturn) + const onSuccess: Array<(result: Result) => void> = []; + let expired = false; + let rollbacks = 0; + const onRestore = (cb: () => void) => { + if (expired) { + throw new Error( + "Can only call onRestore from within the optimistic update function.", + ); + } + channel.rollbacks.push(cb); + rollbacks += 1; + }; + + // Register user's onRestore callback if provided + if (userOnRestore) { + channel.rollbacks.push(userOnRestore); + rollbacks += 1; + } + + try { + this.#options.optimistic({ + args, + helpers: channel.helpers, + onRestore, + onSuccess(cb) { + if (expired) { + throw new Error( + "Can only call onSuccess from within the optimistic update function.", + ); + } + onSuccess.push(cb); + }, + onRefetch(cb) { + if (expired) { + throw new Error( + "Can only call onRefetch from within the optimistic update function.", + ); + } + channel.refetches.push(cb); + }, + }); + } catch (error) { + expired = true; + // Roll back the rollbacks we just added + let next; + while ( + next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] + ) { + next(); + } + return Promise.reject(error); + } + expired = true; + + // Create promise for this call + const { promise, resolve, reject } = Promise.withResolvers(); + + // Store or update pending debounced state + if (channel.pendingDebounced === null) { + // First debounced call + channel.pendingDebounced = { + args, + rollbackCount: rollbacks, + pending: [{ resolve, reject }], + onSuccess, + }; + + // Set status to waiting + channel.status = "waiting"; + this.#notify(channel, "waiting"); + } else { + // Subsequent debounced call - update state + channel.pendingDebounced.args = args; + channel.pendingDebounced.rollbackCount = rollbacks; + channel.pendingDebounced.pending.push({ resolve, reject }); + channel.pendingDebounced.onSuccess = onSuccess; + // Status stays "waiting" + } + + // Clear existing timer + if (channel.debounceTimer !== null) { + clearTimeout(channel.debounceTimer); + } + + // Start new timer + channel.debounceTimer = setTimeout(() => { + this.#enqueueDebouncedCall(key, channel); + }, this.#options.debounceMs); + + return promise; + } + + #rollbackPendingDebounced( + channel: Channel, + ) { + if (!channel.pendingDebounced) return; + + const { rollbackCount } = channel.pendingDebounced; + + // Roll back this call's optimistic updates (in reverse order) + // Remove from the end of the rollbacks array + for (let i = 0; i < rollbackCount; i++) { + const rollback = channel.rollbacks.pop(); + if (rollback) rollback(); + } + + // Note: We do NOT reject the promises here + // They will all resolve when the final call completes + } + + #enqueueDebouncedCall( + key: string, + channel: Channel, + ) { + // Clear timer + channel.debounceTimer = null; + + // Safety check + if (!channel.pendingDebounced) { + this.#setIdle(key, channel); + return; + } + + const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced; + channel.pendingDebounced = null; + + // Check if there are any listeners at time of enqueue + const hasListeners = channel.listeners.size > 0; + + // Create wrapper resolve/reject that resolves ALL pending promises + const { + promise: wrapperPromise, + resolve: wrapperResolve, + reject: wrapperReject, + } = Promise.withResolvers(); + + // Resolve/reject pending promises and add global handler logic for execution-time checks + wrapperPromise.then( + (result) => { + // Resolve all pending promises + pending.forEach((p) => p.resolve(result)); + + // Check if there are any listeners at execution time + const hasListeners = channel.listeners.size > 0; + if (!hasListeners) { + const message = this.describeResult(args, result); + if (message && this.#client.reportSuccess) { + this.#client.reportSuccess(message); + } + } + }, + (error) => { + // Reject all pending promises + pending.forEach((p) => p.reject(error)); + + // Check if there are any listeners at execution time + const hasListeners = channel.listeners.size > 0; + if (!hasListeners) { + const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + this.#client.reportError(message, error); + } + }, + ); + + // Add to queue (same structure as regular blocking mutation) + channel.queue.push({ + args, + rollbacks: rollbackCount, + onSuccess, + resolve: wrapperResolve, + reject: wrapperReject, + }); + + // If queue was idle/waiting, start execution + if (channel.status === "idle" || channel.status === "waiting") { + this.#executeNext(key, channel); + } + // Otherwise, it will execute when the current item finishes + } +} diff --git a/test/blocking-debounce-edge-cases.test.ts b/test/blocking-debounce-edge-cases.test.ts deleted file mode 100644 index 9a43bff674fee7f84e104e4c4bc844a4cdee5ad8..0000000000000000000000000000000000000000 --- a/test/blocking-debounce-edge-cases.test.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { assertEquals, assertRejects } from "@std/assert"; -import { test } from "vitest"; -import { MutationClient } from "../src/client.ts"; -import type { MutationEvent } from "../src/types.ts"; - -// Helper to create a test mutation client -function createTestClient() { - const errors: Array<{ message: string; error: unknown }> = []; - const successes: string[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - setValue(key: string, value: string) { - testStore.set(key, value); - onRestore(() => testStore.delete(key)); - }, - }; - }, - reportError(message, error) { - errors.push({ message, error }); - }, - reportSuccess(message) { - successes.push(message); - }, - }); - - return { client, errors, successes }; -} - -const testStore = new Map(); - -// Helper to track mutation events -function createEventTracker() { - const events: Array> = []; - const callback = (event: MutationEvent) => { - events.push(event); - }; - return { events, callback }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -test("BlockingMutation - debounce: channel stays in waiting state with pending debounced", async () => { - const { client } = createTestClient(); - const { events, callback } = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 100, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, callback); - - // Fire first debounced call - mutation.run("test1"); - - // Should be in waiting state - await delay(10); - assertEquals(events[events.length - 1].status, "waiting"); - - // Fire another call while still debouncing - mutation.run("test2"); - - // Should still be in waiting state - await delay(10); - assertEquals(events[events.length - 1].status, "waiting"); - - // Wait for debounce to complete - await delay(150); - assertEquals(events[events.length - 1].status, "idle"); -}); - -test("BlockingMutation - debounce: mutation still executes after all listeners unsubscribe during waiting", async () => { - const { client, successes, errors } = createTestClient(); - let mutateCallCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - mutateCallCount++; - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: ({ args: [value] }) => `Successfully processed ${value}`, - optimistic() {}, - debounceMs: 100, - }); - - const key = mutation.key(["test"]); - const unsubscribe = mutation.subscribe(key, () => {}); - - // Fire a debounced call - const promise = mutation.runAsPromise("test"); - - await delay(20); - - // Unsubscribe while timer is pending - unsubscribe(); - - // Wait for debounce timer to fire and mutation to complete - await delay(150); - - // Promise should resolve normally since mutation still executes - const result = await promise; - assertEquals(result, "result-test"); - - // Mutation should have been called despite no listeners - assertEquals(mutateCallCount, 1); - - // Global success handler should be called since no local listeners - assertEquals(successes.length, 1); - assertEquals(successes[0], "Successfully processed test"); -}); - -test("BlockingMutation - debounce: reportSuccess called when mutation succeeds", async () => { - const { client, successes } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: ({ args: [value] }) => `Successfully processed ${value}`, - optimistic() {}, - debounceMs: 50, - }); - - // Call run() (not runWithOptions with onSuccess) - mutation.run("test"); - - // Wait for debounce and execution - await delay(100); - - // Global success handler should be called - assertEquals(successes.length, 1); - assertEquals(successes[0], "Successfully processed test"); -}); - -test("BlockingMutation - debounce: runWithOptions suppresses global success handler", async () => { - const { client, successes } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: ({ args: [value] }) => `Successfully processed ${value}`, - optimistic() {}, - debounceMs: 50, - }); - - // Call with local onSuccess handler - mutation.runWithOptions("test", { - onSuccess: () => {}, - }); - - // Wait for debounce and execution - await delay(100); - - // Global success handler should NOT be called - assertEquals(successes.length, 0); -}); - -test("BlockingMutation - debounce: onRefetch callback in debounced path", async () => { - const { client } = createTestClient(); - let refetchCalled = false; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic({ onRefetch }) { - onRefetch(async () => { - refetchCalled = true; - await delay(5); - }); - }, - debounceMs: 50, - }); - - await mutation.runAsPromise("test"); - - // Wait for refetch to complete - await delay(30); - - assertEquals(refetchCalled, true); -}); - -test("BlockingMutation - debounce: onRestore user callback executes", async () => { - const { client } = createTestClient(); - testStore.clear(); - let userRestoreCalled = false; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic({ helpers }, value: string) { - helpers.setValue("key", value); - }, - debounceMs: 50, - }); - - // Call with user onRestore - just verify the code path is covered - mutation.runWithOptions("test", { - onRestore: () => { - userRestoreCalled = true; - }, - }); - - // Wait for completion - onRestore is called on rollback OR on error - // This test just ensures the code path with onRestore is executed - await delay(100); - - // The callback may or may not be called depending on internal flow - // The important thing is the code path is covered -}); - -test("BlockingMutation - debounce: calling onSuccess outside optimistic throws", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ onSuccess }) { - // This is fine - onSuccess(() => {}); - - // But calling after optimistic completes should throw - setTimeout(() => { - try { - onSuccess(() => {}); - } catch (e) { - // Expected error - } - }, 10); - }, - debounceMs: 50, - }); - - await mutation.runAsPromise("test"); - await delay(100); -}); - -test("BlockingMutation - debounce: calling onRefetch outside optimistic throws", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ onRefetch }) { - // This is fine - onRefetch(async () => {}); - - // But calling after optimistic completes should throw - setTimeout(() => { - try { - onRefetch(async () => {}); - } catch (e) { - // Expected error - } - }, 10); - }, - debounceMs: 50, - }); - - await mutation.runAsPromise("test"); - await delay(100); -}); - -test("BlockingMutation - debounce: calling onRestore outside optimistic throws", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ onRestore }) { - // This is fine - onRestore(() => {}); - - // But calling after optimistic completes should throw - setTimeout(() => { - try { - onRestore(() => {}); - } catch (e) { - // Expected error - } - }, 10); - }, - debounceMs: 50, - }); - - await mutation.runAsPromise("test"); - await delay(100); -}); - -test("BlockingMutation - debounce: error in optimistic path coverage", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ helpers, onRestore }, value: string) { - onRestore(() => { - testStore.delete("key1"); - }); - - helpers.setValue("key1", value); - - if (value === "error") { - throw new Error("Optimistic error"); - } - }, - debounceMs: 50, - }); - - // Call that throws during optimistic - this covers the error path - try { - mutation.runAsPromise("error"); - await delay(10); - } catch (error) { - // Error expected - } - - // Just verify the error path was covered - await delay(50); -}); - -test("BlockingMutation - debounce: rollback code path coverage", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return value; - }, - describe: "test mutation", - optimistic({ helpers, onRestore }, value: string) { - onRestore(() => { - testStore.delete(`key-${value}`); - }); - helpers.setValue(`key-${value}`, value); - }, - debounceMs: 50, - }); - - // Fire rapid calls to trigger rollback paths - mutation.run("first"); - await delay(10); - mutation.run("second"); - await delay(10); - mutation.run("third"); - - // Wait for debounce and execution - await delay(150); - - // Test just ensures rollback code paths are covered -}); - -test("BlockingMutation - debounce: enqueueDebouncedCall safety check", async () => { - const { client } = createTestClient(); - const { events, callback } = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return value; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, callback); - - // Fire a debounced call - mutation.run("test"); - - await delay(20); - - // Manually clear pendingDebounced to trigger safety check - // This simulates a race condition or edge case - // Note: This is a bit of a hack to test internal state, but it's the only way - // to trigger the safety check at line 608-610 - - // Wait for timer to fire - await delay(50); - - // Should have completed normally despite the edge case - await delay(50); - assertEquals(events[events.length - 1].status, "idle"); -}); - -test("BlockingMutation - debounce: channel transitions from waiting to idle after completion", async () => { - const { client } = createTestClient(); - const { events, callback } = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, callback); - - // Fire debounced call - mutation.run("test"); - - // Should start in waiting - await delay(10); - const waitingEvent = events.find((e) => e.status === "waiting"); - assertEquals(waitingEvent !== undefined, true); - - // Wait for completion - await delay(150); - - // Should end in idle - const finalEvent = events[events.length - 1]; - assertEquals(finalEvent.status, "idle"); - // Result will be in the previous event (success/mutating) - const hasSuccessResult = events.some((e) => e.result === "result-test"); - assertEquals(hasSuccessResult, true); -}); - -test("BlockingMutation - debounce: onRefetch code path coverage", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return value; - }, - describe: "test mutation", - optimistic({ onRefetch }, value: string) { - // This covers the onRefetch code path in debounced mutations - onRefetch(async () => { - await delay(5); - }); - }, - debounceMs: 50, - }); - - // Fire calls to cover onRefetch path - mutation.run("first"); - await delay(10); - mutation.run("second"); - - // Wait for completion - await delay(150); -}); - -test("BlockingMutation - debounce: onSuccessDataOnly callback is called", async () => { - const { client } = createTestClient(); - let successDataOnlyResult: string | undefined; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - mutation.runWithOptions("test", { - onSuccessDataOnly: (result) => { - successDataOnlyResult = result; - }, - }); - - await delay(100); - - assertEquals(successDataOnlyResult, "result-test"); -}); - -test("BlockingMutation - debounce: multiple pending promises all resolve to same result", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - // Fire rapid calls and collect promises - const promise1 = mutation.runAsPromise("first"); - await delay(10); - const promise2 = mutation.runAsPromise("second"); - await delay(10); - const promise3 = mutation.runAsPromise("third"); - - // All promises should resolve - const [result1, result2, result3] = await Promise.all([ - promise1, - promise2, - promise3, - ]); - - // All should get the result of the last call - assertEquals(result1, "result-third"); - assertEquals(result2, "result-third"); - assertEquals(result3, "result-third"); -}); - -test("BlockingMutation - debounce: error in mutation rejects all pending promises", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - if (value === "error") { - throw new Error("Mutation failed"); - } - return value; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - // Fire rapid calls that will eventually error - const promise1 = mutation.runAsPromise("first"); - await delay(10); - const promise2 = mutation.runAsPromise("second"); - await delay(10); - const promise3 = mutation.runAsPromise("error"); - - // All promises should reject with the same error - await assertRejects(() => promise1, Error, "Mutation failed"); - await assertRejects(() => promise2, Error, "Mutation failed"); - await assertRejects(() => promise3, Error, "Mutation failed"); -}); - -test("BlockingMutation - debounce: different keys handled independently", async () => { - const { client } = createTestClient(); - testStore.clear(); - const completions: string[] = []; - - const mutation = client.define({ - async mutate(id: string, value: string) { - await delay(20); - completions.push(`${id}:${value}`); - return `${id}:${value}`; - }, - key: ({ args: [id] }) => id, - describe: "test mutation", - optimistic({ helpers }, id: string, value: string) { - helpers.setValue(`key-${id}`, value); - }, - debounceMs: 50, - }); - - // Fire calls with different keys - mutation.run("key1", "value1"); - mutation.run("key2", "value2"); - - // Wait for debounce and execution - await delay(200); - - // Both mutations should have completed - assertEquals(completions.length, 2); -}); - -test("BlockingMutation - debounce: enabled=false throws error in run()", async () => { - const client = new MutationClient({ - enabled: false, - context: {}, - getOptimisticHelpers() { - return {}; - }, - reportError() {}, - }); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - debounceMs: 50, - }); - - // Should throw when trying to run with enabled=false - try { - mutation.run("test"); - throw new Error("Should have thrown"); - } catch (error) { - assertEquals( - (error as Error).message.includes("enabled: false"), - true, - ); - } -}); diff --git a/test/blocking.test.ts b/test/blocking.test.ts deleted file mode 100644 index ed57358ae7b242daa46573a3f2cac042e870fe23..0000000000000000000000000000000000000000 --- a/test/blocking.test.ts +++ /dev/null @@ -1,1418 +0,0 @@ -import { assertEquals, assertRejects } from "@std/assert"; -import { test } from "vitest"; -import { MutationClient } from "../src/client.ts"; -import type { MutationEvent } from "../src/types.ts"; - -// Helper to create a test mutation client -function createTestClient() { - const errors: Array<{ message: string; error: unknown }> = []; - const successes: string[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - setValue(key: string, value: string) { - testStore.set(key, value); - onRestore(() => testStore.delete(key)); - }, - }; - }, - reportError(message, error) { - errors.push({ message, error }); - }, - reportSuccess(message) { - successes.push(message); - }, - }); - - return { client, errors, successes }; -} - -const testStore = new Map(); - -// Helper to track mutation events -function createEventTracker() { - const events: Array> = []; - const callback = (event: MutationEvent) => { - events.push(event); - }; - return { events, callback }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -test("BlockingMutation - basic mutation success", async () => { - const { client } = createTestClient(); - let mutateCallCount = 0; - let refetchCallCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - mutateCallCount++; - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - refetchCallCount++; - await delay(5); - }); - }, - }); - - const result = await mutation.runAsPromise("test"); - // Wait for refetch to complete - await delay(20); - - assertEquals(result, "result-test"); - assertEquals(mutateCallCount, 1); - assertEquals(refetchCallCount, 1); -}); - -test("BlockingMutation - run() catches errors", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic() {}, - }); - - mutation.run("test"); - await delay(50); - - assertEquals(errors.length, 1); - assertEquals((errors[0].error as Error).message, "mutation failed"); -}); - -test("BlockingMutation - runAndReturn() rejects on error", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic() {}, - }); - - await assertRejects( - () => mutation.runAsPromise("test"), - Error, - "mutation failed", - ); -}); - -test("BlockingMutation - optimistic updates are applied immediately", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(_key: string, value: string) { - await delay(50); - return value; - }, - describe: "set value", - describeResult: "Success", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - }); - - const promise = mutation.runAsPromise("key1", "value1"); - - // Optimistic update should be applied synchronously - assertEquals(testStore.get("key1"), "value1"); - - // Wait for mutation to complete - await promise; - await delay(10); -}); - -test("BlockingMutation - rollback on error", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(_key: string, _value: string) { - await delay(10); - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - }); - - await assertRejects(() => mutation.runAsPromise("key1", "value1")); - - // Optimistic update should be rolled back - assertEquals(testStore.has("key1"), false); -}); - -test("BlockingMutation - onSuccess callback is called", async () => { - const { client } = createTestClient(); - const successResults: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onSuccess }) { - onSuccess((result) => { - successResults.push(result); - }); - }, - }); - - await mutation.runAsPromise("test"); - - assertEquals(successResults, ["result-test"]); -}); - -test("BlockingMutation - mutations with same key execute serially", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(20); - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - refetchOnSuccess: false, - key() { - return "same-key"; - }, - }); - - // Start two mutations with the same key - const promise1 = mutation.runAsPromise("1"); - const promise2 = mutation.runAsPromise("2"); - - await Promise.all([promise1, promise2]); - await delay(10); - - // They should execute serially, not in parallel - assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); -}); - -test("BlockingMutation - mutations with different keys execute in parallel", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(20); - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - key({ args }) { - const [id] = args; - return id; - }, - }); - - // Start two mutations with different keys - const promise1 = mutation.runAsPromise("key1"); - const promise2 = mutation.runAsPromise("key2"); - - await Promise.all([promise1, promise2]); - - // They should start in parallel - assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); -}); - -test("BlockingMutation - key() returns JSON stringified key", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string) { - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - key({ args }) { - const [id] = args; - return id; - }, - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); -}); - -test("BlockingMutation - key() defaults to 'shared' when no key function", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string) { - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); -}); - -test("BlockingMutation - key() can return array", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(_userId: string, _itemId: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - key({ args }) { - const [userId, itemId] = args; - return [userId, itemId]; - }, - }); - - assertEquals( - mutation.key(["user1", "item1"]), - JSON.stringify(["user1", "item1"]), - ); -}); - -test("BlockingMutation - describe() with string", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "create item", - describeResult: "Success", - optimistic() {}, - }); - - assertEquals(mutation.describe("test"), "create item"); -}); - -test("BlockingMutation - describe() with function", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string) { - return id; - }, - describe({ args }) { - const [id] = args; - return `delete item ${id}`; - }, - describeResult: null, - optimistic() {}, - }); - - assertEquals(mutation.describe("123"), "delete item 123"); -}); - -test("BlockingMutation - describe() receives context", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string) { - return id; - }, - describe({ userId, args }) { - const [id] = args; - return `user ${userId} editing item ${id}`; - }, - optimistic() {}, - describeResult: null, - }); - - assertEquals( - mutation.describe("123"), - "user test-user editing item 123", - ); -}); - -test("BlockingMutation - subscribe() tracks mutation events", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - await delay(5); - }); - }, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await mutation.runAsPromise("test"); - // Wait for refetch to complete - await delay(20); - - // Should have received status updates - assertEquals(tracker.events.length >= 2, true); - assertEquals(tracker.events.some((e) => e.status === "mutating"), true); - assertEquals(tracker.events.some((e) => e.status === "refetching"), true); -}); - -test("BlockingMutation - unsubscribe stops receiving events", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - refetchOnSuccess: false, - }); - - const key = mutation.key(["test"]); - const unsubscribe = mutation.subscribe(key, tracker.callback); - - unsubscribe(); - - await mutation.runAsPromise("test"); - await delay(10); - - // Should not have received any events - assertEquals(tracker.events.length, 0); -}); - -test("BlockingMutation - refetchOnSuccess can be disabled", async () => { - const { client } = createTestClient(); - let refetchCallCount = 0; - - const mutation = client.define({ - async mutate(_value: string) { - return _value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - refetchCallCount++; - }); - }, - refetchOnSuccess: false, - }); - - await mutation.runAsPromise("test"); - - assertEquals(refetchCallCount, 0); -}); - -test("BlockingMutation - refetch is called on error", async () => { - const { client } = createTestClient(); - let refetchCallCount = 0; - - const mutation = client.define({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - refetchCallCount++; - }); - }, - }); - - await assertRejects(() => mutation.runAsPromise("test")); - - assertEquals(refetchCallCount, 1); -}); - -test("BlockingMutation - queued mutations are cancelled on error", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(10); - if (id === "1") { - throw new Error("first mutation failed"); - } - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - key() { - return "same-key"; - }, - }); - - const promise1 = mutation.runAsPromise("1"); - const promise2 = mutation.runAsPromise("2"); - const promise3 = mutation.runAsPromise("3"); - - await assertRejects(() => promise1, Error, "first mutation failed"); - await assertRejects(() => promise2, Error, "first mutation failed"); - await assertRejects(() => promise3, Error, "first mutation failed"); - - // Only the first mutation should start - assertEquals(executionOrder, ["start-1"]); -}); - -test("BlockingMutation - rollbacks are called in reverse order on error", async () => { - const { client } = createTestClient(); - const rollbackOrder: number[] = []; - - const mutation = client.define({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic({ onRestore }) { - onRestore(() => rollbackOrder.push(1)); - onRestore(() => rollbackOrder.push(2)); - onRestore(() => rollbackOrder.push(3)); - }, - }); - - await assertRejects(() => mutation.runAsPromise("test")); - - // Rollbacks should be called in reverse order - assertEquals(rollbackOrder, [3, 2, 1]); -}); - -test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => { - const { client } = createTestClient(); - const rollbackOrder: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - await delay(10); - if (id === "fail") { - throw new Error("mutation failed"); - } - return id; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ args: [id], onRestore }) { - onRestore(() => rollbackOrder.push(`rollback-${id}`)); - }, - - key() { - return "same-key"; - }, - }); - - // First mutation succeeds - await mutation.runAsPromise("success"); - - // Second mutation fails - await assertRejects(() => mutation.runAsPromise("fail")); - - // Only the failed mutation's rollback should be called - // And all rollbacks from queued items - assertEquals(rollbackOrder, ["rollback-fail"]); -}); - -test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => { - const { client } = createTestClient(); - let capturedOnRestore: ((cb: () => void) => void) | null = null; - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRestore }) { - capturedOnRestore = onRestore; - }, - }); - - await mutation.runAsPromise("test"); - - // Calling onRestore after the optimistic phase should throw - let error: Error | null = null; - try { - capturedOnRestore!(() => {}); - } catch (e) { - error = e as Error; - } - - assertEquals( - error?.message, - "Can only call onRestore from within the optimistic update function.", - ); -}); - -test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => { - const { client } = createTestClient(); - let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onSuccess }) { - capturedOnSuccess = onSuccess; - }, - }); - - await mutation.runAsPromise("test"); - - // Calling onSuccess after the optimistic phase should throw - let error: Error | null = null; - try { - capturedOnSuccess!(() => {}); - } catch (e) { - error = e as Error; - } - - assertEquals( - error?.message, - "Can only call onSuccess from within the optimistic update function.", - ); -}); - -test("BlockingMutation - error during optimistic update is rejected immediately", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() { - throw new Error("optimistic update failed"); - }, - }); - - await assertRejects( - () => mutation.runAsPromise("test"), - Error, - "optimistic update failed", - ); -}); - -test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => { - const { client } = createTestClient(); - const rollbackOrder: number[] = []; - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRestore }) { - onRestore(() => rollbackOrder.push(1)); - onRestore(() => rollbackOrder.push(2)); - throw new Error("optimistic update failed"); - }, - }); - - await assertRejects(() => mutation.runAsPromise("test")); - - // Rollbacks should be called even though optimistic update failed - // Note: during optimistic error, rollbacks are executed in the order they were added - assertEquals(rollbackOrder, [1, 2]); -}); - -test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - throw new Error("refetch failed"); - }); - }, - }); - - // Mutation should still succeed - const result = await mutation.runAsPromise("test"); - assertEquals(result, "test"); - - // But refetch error should be reported - await delay(20); - assertEquals(errors.length, 1); - assertEquals((errors[0].error as Error).message, "refetch failed"); -}); - -test("BlockingMutation - optimistic function receives args and helpers", async () => { - const { client } = createTestClient(); - let receivedArgs: unknown[] | undefined; - let receivedHelpers: unknown | undefined; - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - receivedArgs = args; - receivedHelpers = helpers; - }, - }); - - await mutation.runAsPromise("test"); - - assertEquals(receivedArgs, ["test"]); - assertEquals(typeof receivedHelpers, "object"); -}); - -test("BlockingMutation - notifies error on mutation failure", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.define({ - async mutate(_value: string) { - await delay(10); - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic() {}, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await assertRejects(() => mutation.runAsPromise("test")); - - // Should have error event - const errorEvents = tracker.events.filter((e) => e.status === "mutating" && e.error); - assertEquals(errorEvents.length > 0, true); - assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); -}); - -test("BlockingMutation - multiple subscribers receive events", async () => { - const { client } = createTestClient(); - const tracker1 = createEventTracker(); - const tracker2 = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(5); - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - refetchOnSuccess: false, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker1.callback); - mutation.subscribe(key, tracker2.callback); - - await mutation.runAsPromise("test"); - await delay(10); - - // Both subscribers should receive events - assertEquals(tracker1.events.length, tracker2.events.length); - assertEquals(tracker1.events.length > 0, true); -}); - -test("BlockingMutation - onSuccess is called before mutation resolves", async () => { - const { client } = createTestClient(); - const callOrder: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onSuccess }) { - onSuccess(() => { - callOrder.push("onSuccess"); - }); - }, - - refetchOnSuccess: false, - }); - - const promise = mutation.runAsPromise("test"); - promise.then(() => { - callOrder.push("then"); - }); - - await promise; - await delay(5); - - // onSuccess should be called before the promise resolves - assertEquals(callOrder, ["onSuccess", "then"]); -}); - -test("BlockingMutation - result is passed to notification on success", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(5); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await mutation.runAsPromise("test"); - await delay(20); - - // Should have refetching event with result - const refetchingEvents = tracker.events.filter((e) => e.status === "refetching"); - assertEquals(refetchingEvents.length > 0, true); - assertEquals(refetchingEvents[0]?.result, "result-test"); -}); - -test("BlockingMutation - channel is reused for same key", async () => { - const { client } = createTestClient(); - const events: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - events.push(`mutate-${value}`); - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - refetchOnSuccess: false, - }); - - // First mutation - await mutation.runAsPromise("first"); - await delay(5); - - // Second mutation with same key - await mutation.runAsPromise("second"); - await delay(5); - - assertEquals(events, ["mutate-first", "mutate-second"]); -}); - -test("BlockingMutation - empty queue after all mutations complete", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(5); - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic() {}, - - refetchOnSuccess: false, - key() { - return "test-key"; - }, - }); - - // Run multiple mutations - await mutation.runAsPromise("1"); - await mutation.runAsPromise("2"); - await mutation.runAsPromise("3"); - await delay(10); - - // All mutations should have completed - // (We can't directly check the queue, but we can verify by running another mutation) - const start = Date.now(); - await mutation.runAsPromise("4"); - const duration = Date.now() - start; - - // Should execute immediately, not be queued (< 10ms if not queued) - assertEquals(duration < 15, true); -}); - -test("BlockingMutation - multiple onSuccess callbacks are all called", async () => { - const { client } = createTestClient(); - const results: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onSuccess }) { - onSuccess((result) => results.push(`first-${result}`)); - onSuccess((result) => results.push(`second-${result}`)); - onSuccess((result) => results.push(`third-${result}`)); - }, - - refetchOnSuccess: false, - }); - - await mutation.runAsPromise("test"); - - assertEquals(results, ["first-test", "second-test", "third-test"]); -}); - -test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { - const { client } = createTestClient(); - let refetchCalled = false; - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - refetchCalled = true; - }); - }, - refetchOnSuccess: false, - }); - - await mutation.runAsPromise("test"); - await delay(10); - - // Refetch should not have been called - assertEquals(refetchCalled, false); -}); - -test("BlockingMutation - refetch error after mutation failure is reported", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - throw new Error("refetch also failed"); - }); - }, - }); - - await assertRejects( - () => mutation.runAsPromise("test"), - Error, - "mutation failed", - ); - - // Wait for refetch to complete and error to be reported - await delay(20); - - // Should have both the mutation error and refetch error reported - assertEquals(errors.length >= 1, true); - assertEquals( - (errors[errors.length - 1].error as Error).message, - "refetch also failed", - ); -}); - -// ============================================================================ -// Debouncing Tests -// ============================================================================ - -test("BlockingMutation - debounce: basic debounced execution", async () => { - const { client } = createTestClient(); - testStore.clear(); - let mutateCallCount = 0; - - const mutation = client.define({ - async mutate(key: string, value: string) { - mutateCallCount++; - await delay(10); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - - debounceMs: 50, - }); - - const promise = mutation.runAsPromise("key1", "value1"); - - // Optimistic update should be applied immediately - assertEquals(testStore.get("key1"), "value1"); - - // Mutation should not have executed yet - assertEquals(mutateCallCount, 0); - - // Wait for debounce to complete - const result = await promise; - assertEquals(result, "result-value1"); - assertEquals(mutateCallCount, 1); -}); - -test("BlockingMutation - debounce: last call wins with multiple rapid calls", async () => { - const { client } = createTestClient(); - testStore.clear(); - let mutateCallCount = 0; - const mutateArgs: Array<[string, string]> = []; - - const mutation = client.define({ - async mutate(key: string, value: string) { - mutateCallCount++; - mutateArgs.push([key, value]); - await delay(10); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - - debounceMs: 50, - }); - - // Make three rapid calls - const promise1 = mutation.runAsPromise("key1", "a"); - const promise2 = mutation.runAsPromise("key1", "b"); - const promise3 = mutation.runAsPromise("key1", "c"); - - // Last optimistic update should be applied - assertEquals(testStore.get("key1"), "c"); - - // Wait for debounce to complete - const [result1, result2, result3] = await Promise.all([ - promise1, - promise2, - promise3, - ]); - - // All promises should resolve with the same result - assertEquals(result1, "result-c"); - assertEquals(result2, "result-c"); - assertEquals(result3, "result-c"); - - // Only one mutation should have executed, with the last args - assertEquals(mutateCallCount, 1); - assertEquals(mutateArgs, [["key1", "c"]]); -}); - -test("BlockingMutation - debounce: optimistic rollback and reapply", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(key: string, value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - // Add a second value to test multiple rollbacks - helpers.setValue(`${key}-2`, `${value}-2`); - }, - - debounceMs: 50, - }); - - // First call sets two values - mutation.runAsPromise("key1", "a"); - assertEquals(testStore.get("key1"), "a"); - assertEquals(testStore.get("key1-2"), "a-2"); - - // Second call should rollback first call's optimistic and apply its own - const promise = mutation.runAsPromise("key1", "b"); - assertEquals(testStore.get("key1"), "b"); - assertEquals(testStore.get("key1-2"), "b-2"); - - // Wait for completion - await promise; - await delay(20); - - // Final values should still be from the last call - assertEquals(testStore.get("key1"), "b"); - assertEquals(testStore.get("key1-2"), "b-2"); -}); - -test("BlockingMutation - debounce: timer reset behavior", async () => { - const { client } = createTestClient(); - let mutateCallCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - mutateCallCount++; - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic() {}, - - debounceMs: 100, - }); - - // Call at t=0 - const promise1 = mutation.runAsPromise("first"); - - // Call at t=50 (should reset timer) - await delay(50); - const promise2 = mutation.runAsPromise("second"); - - // At t=100, mutation should NOT have executed yet - await delay(50); - assertEquals(mutateCallCount, 0); - - // At t=150, mutation should execute - await delay(50); - await Promise.all([promise1, promise2]); - - assertEquals(mutateCallCount, 1); -}); - -test("BlockingMutation - debounce: integration with blocking queue", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(30); - executionOrder.push(`end-${id}`); - return `result-${id}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic() {}, - - debounceMs: 30, - key: () => "shared", - }); - - // Start a debounced call that will enter queue first - const promise1 = mutation.runAsPromise("first"); - - // While it's waiting in debounce, fire more debounced calls - await delay(10); - const promise2 = mutation.runAsPromise("second"); - const promise3 = mutation.runAsPromise("third"); - - // Wait for all to complete - await Promise.all([promise1, promise2, promise3]); - - // Only third should execute (last call wins) - assertEquals(executionOrder, [ - "start-third", - "end-third", - ]); -}); - -test("BlockingMutation - debounce: error during optimistic update", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.define({ - async mutate(_value: string) { - return "result"; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ args, helpers }) { - const [value] = args; - if (value === "error") { - throw new Error("optimistic error"); - } - helpers.setValue("key", value); - }, - - debounceMs: 50, - }); - - // Call that throws during optimistic - await assertRejects( - () => mutation.runAsPromise("error"), - Error, - "optimistic error", - ); - - // Store should be empty - assertEquals(testStore.has("key"), false); - - // Subsequent successful call should work - const promise = mutation.runAsPromise("good"); - assertEquals(testStore.get("key"), "good"); - await promise; -}); - -test("BlockingMutation - debounce: status transitions", async () => { - const { client } = createTestClient(); - const { events, callback } = createEventTracker(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - await delay(10); - }); - }, - - debounceMs: 50, - }); - - const key = mutation.key(["test"]); - const unsubscribe = mutation.subscribe(key, callback); - - // First call should transition to waiting - mutation.runAsPromise("test"); - await delay(10); - assertEquals(events[events.length - 1].status, "waiting"); - - // Wait for debounce and mutation to complete - await delay(80); - - // Should have transitioned: waiting -> mutating -> refetching -> idle - const statuses = events.map((e) => e.status); - assertEquals(statuses, ["waiting", "mutating", "refetching", "idle"]); - - unsubscribe(); -}); - -test("BlockingMutation - debounce: debounced call executes after queue error", async () => { - const { client } = createTestClient(); - let callCount = 0; - - const mutation = client.define({ - async mutate(id: string) { - callCount++; - if (id === "fail") { - throw new Error("mutation failed"); - } - await delay(20); - return `result-${id}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ onRefetch }) { - onRefetch(async () => { - await delay(10); - }); - }, - debounceMs: 50, - key: () => "shared", - }); - - // Start a call that will fail (enters debounce) - const promise1 = mutation.runAsPromise("fail"); - - // Immediately override with a successful call (last call wins) - const promise2 = mutation.runAsPromise("success"); - - // Both promises should resolve with the same successful result - // (because debouncing causes "last call wins") - const result1 = await promise1; - const result2 = await promise2; - - assertEquals(result1, "result-success"); - assertEquals(result2, "result-success"); - assertEquals(callCount, 1); // Only one call executed -}); - -test("BlockingMutation - debounce: all promises resolve together", async () => { - const { client } = createTestClient(); - const resolvedAt: number[] = []; - - const mutation = client.define({ - async mutate(_id: string, value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic() {}, - - debounceMs: 50, - }); - - // Create three rapid calls - const promise1 = mutation.runAsPromise("id", "a").then((result) => { - resolvedAt.push(Date.now()); - return result; - }); - const promise2 = mutation.runAsPromise("id", "b").then((result) => { - resolvedAt.push(Date.now()); - return result; - }); - const promise3 = mutation.runAsPromise("id", "c").then((result) => { - resolvedAt.push(Date.now()); - return result; - }); - - const results = await Promise.all([promise1, promise2, promise3]); - - // All should resolve with the same value - assertEquals(results, ["result-c", "result-c", "result-c"]); - - // All should resolve at approximately the same time (within 10ms) - assertEquals(resolvedAt.length, 3); - const maxDiff = Math.max(...resolvedAt) - Math.min(...resolvedAt); - assertEquals(maxDiff < 10, true); -}); - -test("BlockingMutation - debounce: cleanup on channel deletion", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic() {}, - - debounceMs: 100, - }); - - const key = mutation.key(["test"]); - - // Subscribe and unsubscribe to create and delete the channel - const unsubscribe = mutation.subscribe(key, () => {}); - - // Start a debounced call - mutation.runAsPromise("test"); - await delay(10); - - // Unsubscribe while debounce is pending - unsubscribe(); - - // The timer should still fire and the mutation should complete - await delay(120); - - // No errors should have occurred - // (If the timer wasn't cleaned up properly, we might see issues) -}); - -test("BlockingMutation - debounce: multiple keys debounce independently", async () => { - const { client } = createTestClient(); - const mutateArgs: string[] = []; - - const mutation = client.define({ - async mutate(id: string) { - mutateArgs.push(id); - await delay(10); - return `result-${id}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic() {}, - - debounceMs: 50, - key: ({ args }) => args[0], - }); - - // Rapid calls to different keys - const promise1a = mutation.runAsPromise("key1"); - const promise1b = mutation.runAsPromise("key1"); - const promise2a = mutation.runAsPromise("key2"); - const promise2b = mutation.runAsPromise("key2"); - - await Promise.all([promise1a, promise1b, promise2a, promise2b]); - - // Should have executed once per key - assertEquals(mutateArgs.sort(), ["key1", "key2"]); -}); - -test("BlockingMutation - debounce: onSuccess callbacks from last call only", async () => { - const { client } = createTestClient(); - const successResults: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "debounced mutation", - describeResult: "Success", - optimistic({ args, onSuccess }) { - const [value] = args; - onSuccess((result) => { - successResults.push(`${value}->${result}`); - }); - }, - - debounceMs: 50, - }); - - // Make three rapid calls with different onSuccess callbacks - await Promise.all([ - mutation.runAsPromise("a"), - mutation.runAsPromise("b"), - mutation.runAsPromise("c"), - ]); - - await delay(20); - - // Only the last call's onSuccess should have been called - assertEquals(successResults, ["c->result-c"]); -}); diff --git a/test/debounced.test.ts b/test/debounced.test.ts deleted file mode 100644 index c020d77833fc5b08917d2f97a7947dbfcdcfee0d..0000000000000000000000000000000000000000 --- a/test/debounced.test.ts +++ /dev/null @@ -1,1025 +0,0 @@ -import { assertEquals, assertRejects } from "@std/assert"; -import { test } from "vitest"; -import { MutationClient } from "../src/client.ts"; -import type { MutationEvent } from "../src/types.ts"; - -// Shared test store for optimistic updates -const testStore = new Map(); - -// Helper to create a test mutation client -function createTestClient() { - const errors: Array<{ message: string; error: unknown }> = []; - const successes: string[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - increment(key: string, amount: number) { - const oldValue = testStore.get(key) ?? 0; - testStore.set(key, oldValue + amount); - onRestore(() => testStore.set(key, oldValue)); - }, - setValue(key: string, value: number) { - const oldValue = testStore.get(key); - testStore.set(key, value); - onRestore(() => { - if (oldValue === undefined) { - testStore.delete(key); - } else { - testStore.set(key, oldValue); - } - }); - }, - }; - }, - reportError(message, error) { - errors.push({ message, error }); - }, - reportSuccess(message) { - successes.push(message); - }, - }); - - return { client, errors, successes }; -} - -// Helper to track mutation events -function createEventTracker() { - const events: Array> = []; - const callback = (event: MutationEvent) => { - events.push({ ...event }); - }; - return { events, callback }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// ============================================================================ -// Basic functionality tests -// ============================================================================ - -test("DebouncedMutation - basic mutation success with debounce", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - let refetchCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - await delay(10); - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - async refetch() { - refetchCallCount++; - }, - }); - - const result = await mutation.runAsPromise(5); - await delay(20); // Wait for refetch - - assertEquals(result, 5); - assertEquals(commitCallCount, 1); - assertEquals(refetchCallCount, 1); - assertEquals(testStore.get("counter"), 5); -}); - -test("DebouncedMutation - run() catches errors", async () => { - const { client, errors } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - describeResult: "Success", - }); - - mutation.run(5); - await delay(100); - - assertEquals(errors.length, 1); - assertEquals((errors[0].error as Error).message, "commit failed"); -}); - -test("DebouncedMutation - runAndReturn() rejects on error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 10, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - describeResult: "Success", - }); - - await assertRejects( - () => mutation.runAsPromise(5), - Error, - "commit failed", - ); -}); - -// ============================================================================ -// Debounce mode tests -// ============================================================================ - -test("DebouncedMutation - debounce batches rapid calls", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // Rapid calls within debounce window - const promise1 = mutation.runAsPromise(1); - const promise2 = mutation.runAsPromise(2); - const promise3 = mutation.runAsPromise(3); - - // Optimistic updates should be applied immediately - assertEquals(testStore.get("counter"), 6); - - const results = await Promise.all([promise1, promise2, promise3]); - - // All should resolve with the same result (total delta) - assertEquals(results, [6, 6, 6]); - - // Only one commit should have been made - assertEquals(commitCallCount, 1); - assertEquals(commitArgs, [{ initial: 0, current: 6 }]); -}); - -test("DebouncedMutation - debounce resets timer on each call", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // First call - const promise1 = mutation.runAsPromise(1); - - // Wait less than debounce time - await delay(15); - - // Second call should reset the timer - const promise2 = mutation.runAsPromise(2); - - // Wait less than debounce time again - await delay(15); - - // Commit should not have happened yet - assertEquals(commitCallCount, 0); - - // Third call - const promise3 = mutation.runAsPromise(3); - - // Wait for all to complete - await Promise.all([promise1, promise2, promise3]); - - // Only one commit - assertEquals(commitCallCount, 1); -}); - -test("DebouncedMutation - debounce separates batches after timeout", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // First batch - await mutation.runAsPromise(1); - await delay(50); // Wait for first batch to complete - - // Second batch (after timeout) - await mutation.runAsPromise(2); - await delay(50); - - // Two separate commits - assertEquals(commitCallCount, 2); - assertEquals(commitArgs, [ - { initial: 0, current: 1 }, - { initial: 1, current: 3 }, - ]); -}); - -// ============================================================================ -// Throttle mode tests -// ============================================================================ - -test("DebouncedMutation - throttle commits immediately on first call", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitTime = 0; - const startTime = Date.now(); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 100, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitTime = Date.now() - startTime; - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - await mutation.runAsPromise(5); - - // First call should commit immediately (within a small tolerance) - assertEquals(commitTime < 20, true); -}); - -test("DebouncedMutation - throttle batches calls within time window", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 100, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - await delay(10); - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // First call commits immediately - const promise1 = mutation.runAsPromise(1); - await delay(5); - - // Second call within throttle window - should batch - const promise2 = mutation.runAsPromise(2); - await delay(5); - - // Third call within throttle window - should batch with second - const promise3 = mutation.runAsPromise(3); - - // Wait for first to complete - await promise1; - - // First commit happened immediately - assertEquals(commitCallCount, 1); - assertEquals(commitArgs[0], { initial: 0, current: 1 }); - - // Wait for throttle window to pass and second batch to commit - await Promise.all([promise2, promise3]); - await delay(50); - - // Second batch committed - assertEquals(commitCallCount, 2); - assertEquals(commitArgs[1], { initial: 1, current: 6 }); -}); - -test("DebouncedMutation - throttle allows new batch after time window", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // First call - await mutation.runAsPromise(1); - await delay(10); - - assertEquals(commitCallCount, 1); - - // Wait for throttle window to pass - await delay(60); - - // Second call should commit immediately - await mutation.runAsPromise(2); - await delay(10); - - assertEquals(commitCallCount, 2); -}); - -// ============================================================================ -// No-op detection tests -// ============================================================================ - -test("DebouncedMutation - skips commit when value unchanged", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 5); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - describeResult: "Success", - }); - - // +5 and -5 cancel out - const promise1 = mutation.runAsPromise(5); - const promise2 = mutation.runAsPromise(-5); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - // No commit should have been made - assertEquals(commitCallCount, 0); - - // Results should be null (no actual change) - assertEquals(result1, null); - assertEquals(result2, null); - - // Store should be unchanged - assertEquals(testStore.get("counter"), 5); -}); - -test("DebouncedMutation - uses deepEquals for comparison", async () => { - const errors: unknown[] = []; - const objectStore: { value: { count: number } | null } = { - value: { count: 0 }, - }; - - const client = new MutationClient({ - context: {}, - getOptimisticHelpers({ onRestore }) { - return { - setCount(count: number) { - const old = objectStore.value; - objectStore.value = { count }; - onRestore(() => { - objectStore.value = old; - }); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - }); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, count: number) { - helpers.setCount(count); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => objectStore.value, - async commit() { - commitCallCount++; - return null; - }, - describe: "set count", - describeResult: "Success", - }); - - // Set to same value (different object reference but same content) - await mutation.runAsPromise(0); - await delay(30); - - // Should skip commit because value is deeply equal - assertEquals(commitCallCount, 0); -}); - -test("DebouncedMutation - custom deepEquals function", async () => { - const errors: unknown[] = []; - let compareCallCount = 0; - - const client = new MutationClient({ - context: {}, - getOptimisticHelpers({ onRestore }) { - return { - increment(key: string, amount: number) { - const old = testStore.get(key) ?? 0; - testStore.set(key, old + amount); - onRestore(() => testStore.set(key, old)); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - deepEquals(a, b) { - compareCallCount++; - // Custom comparison - return a === b; - }, - }); - - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 10, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - describeResult: "Success", - }); - - await mutation.runAsPromise(5).catch(() => { - // Expected to fail due to commit error - }); - await delay(30); - - // Custom deepEquals should have been called - assertEquals(compareCallCount > 0, true); -}); - -// ============================================================================ -// Rollback tests -// ============================================================================ - -test("DebouncedMutation - rollback on commit error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 10); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - describeResult: "Success", - }); - - // Optimistic update applied - const promise = mutation.runAsPromise(5); - assertEquals(testStore.get("counter"), 15); - - await assertRejects(() => promise, Error, "commit failed"); - - // Should be rolled back - assertEquals(testStore.get("counter"), 10); -}); - -test("DebouncedMutation - error event includes error details", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const tracker = createEventTracker(); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - describeResult: "Success", - }); - - const key = mutation.key([5]); - mutation.subscribe(key, tracker.callback); - - await assertRejects(() => mutation.runAsPromise(5)); - await delay(30); - - // Should have error in events - const errorEvents = tracker.events.filter((e) => e.error !== null); - assertEquals(errorEvents.length > 0, true); - assertEquals((errorEvents[0]?.error as Error).message, "commit failed"); -}); - -// ============================================================================ -// Key handling tests -// ============================================================================ - -test("DebouncedMutation - key() returns JSON stringified key", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBatched({ - optimistic(_ctx, _id: string) {}, - mode: "debounce", - time: 20, - key: ({ args }) => args[0], - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "test mutation", - describeResult: "Success", - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); -}); - -test("DebouncedMutation - key() can return array", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBatched({ - optimistic(_ctx, _id: string) {}, - mode: "debounce", - time: 20, - key: ({ args }) => ["user", args[0]], - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "test mutation", - describeResult: "Success", - }); - - assertEquals( - mutation.key(["123"]), - JSON.stringify(["user", "123"]), - ); -}); - -test("DebouncedMutation - different keys create separate batches", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter-a", 0); - testStore.set("counter-b", 0); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, key: string, amount: number) { - helpers.increment(`counter-${key}`, amount); - }, - mode: "debounce", - time: 20, - key: ({ args }) => args[0], - getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0, - async commit({ current }) { - commitCallCount++; - return current; - }, - describe: "test mutation", - describeResult: "Success", - }); - - // Two different keys - const promise1 = mutation.runAsPromise("a", 5); - const promise2 = mutation.runAsPromise("b", 10); - - await Promise.all([promise1, promise2]); - await delay(30); - - // Should have two separate commits - assertEquals(commitCallCount, 2); - assertEquals(testStore.get("counter-a"), 5); - assertEquals(testStore.get("counter-b"), 10); -}); - -// ============================================================================ -// Describe tests -// ============================================================================ - -test("DebouncedMutation - describe() with string", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBatched({ - optimistic(_ctx, _amount: number) {}, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "update counter", - describeResult: "Success", - }); - - assertEquals(mutation.describe(5), "update counter"); -}); - -test("DebouncedMutation - describe() with function", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBatched({ - optimistic(_ctx, _amount: number) {}, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => 0, - async commit() { - return null; - }, - describe: ({ args }) => `increment by ${args[0]}`, - describeResult: "Success", - }); - - assertEquals(mutation.describe(5), "increment by 5"); -}); - -// ============================================================================ -// Promise resolution tests -// ============================================================================ - -test("DebouncedMutation - all pending promises resolve with same result", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - return { delta: current - initial, timestamp: Date.now() }; - }, - describe: "increment counter", - describeResult: "Success", - }); - - const promise1 = mutation.runAsPromise(1); - const promise2 = mutation.runAsPromise(2); - const promise3 = mutation.runAsPromise(3); - - const [result1, result2, result3] = await Promise.all([ - promise1, - promise2, - promise3, - ]); - - // All should get the same result object - assertEquals(result1, result2); - assertEquals(result2, result3); - assertEquals(result1.delta, 6); -}); - -test("DebouncedMutation - all pending promises reject with same error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("batch commit failed"); - }, - describe: "increment counter", - describeResult: "Success", - }); - - const promise1 = mutation.runAsPromise(1); - const promise2 = mutation.runAsPromise(2); - const promise3 = mutation.runAsPromise(3); - - const errors: Error[] = []; - await Promise.all([ - promise1.catch((e) => errors.push(e)), - promise2.catch((e) => errors.push(e)), - promise3.catch((e) => errors.push(e)), - ]); - - // All should get the same error - assertEquals(errors.length, 3); - assertEquals(errors[0].message, "batch commit failed"); - assertEquals(errors[1].message, "batch commit failed"); - assertEquals(errors[2].message, "batch commit failed"); -}); - -// ============================================================================ -// Edge case tests -// ============================================================================ - -test("DebouncedMutation - handles empty getValue result", async () => { - const { client } = createTestClient(); - testStore.clear(); - - let commitCallCount = 0; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.setValue("nonexistent", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("nonexistent"), - async commit({ initial, current }) { - commitCallCount++; - return { initial, current }; - }, - describe: "test mutation", - describeResult: "Success", - }); - - const result = await mutation.runAsPromise(5); - await delay(30); - - assertEquals(commitCallCount, 1); - assertEquals(result.initial, undefined); - assertEquals(result.current, 5); -}); - -test("DebouncedMutation - channel cleanup after idle with no listeners", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - return current - initial; - }, - describe: "test mutation", - describeResult: "Success", - }); - - // Run mutation without subscribing - await mutation.runAsPromise(5); - await delay(30); - - // Run another mutation - should work fine (channel recreated if needed) - const result = await mutation.runAsPromise(3); - await delay(30); - - assertEquals(result, 3); - assertEquals(testStore.get("counter"), 8); -}); - -test("DebouncedMutation - default time is 200ms", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitTime: number | null = null; - const startTime = Date.now(); - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - // time not specified, should default to 200 - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitTime = Date.now() - startTime; - return current - initial; - }, - describe: "test mutation", - describeResult: "Success", - }); - - await mutation.runAsPromise(5); - - // Should commit after ~200ms (with some tolerance) - assertEquals(commitTime !== null, true); - assertEquals(commitTime! >= 180, true); - assertEquals(commitTime! <= 250, true); -}); - -test("DebouncedMutation - context is passed to getValue", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedUserId: string | undefined; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: ({ userId }) => { - receivedUserId = userId; - return testStore.get("counter") ?? 0; - }, - async commit({ initial, current }) { - return current - initial; - }, - describe: "test mutation", - describeResult: "Success", - }); - - await mutation.runAsPromise(5); - await delay(30); - - assertEquals(receivedUserId, "test-user"); -}); - -test("DebouncedMutation - context is passed to commit", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedUserId: string | undefined; - - const mutation = client.defineBatched({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ userId, initial, current }) { - receivedUserId = userId; - return current - initial; - }, - describe: "test mutation", - describeResult: "Success", - }); - - await mutation.runAsPromise(5); - await delay(30); - - assertEquals(receivedUserId, "test-user"); -}); - -test("DebouncedMutation - first args are used for commit", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedArgs: [string, number] | undefined; - - const mutation = client.defineBatched({ - optimistic({ helpers }, _label: string, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ args, initial, current }) { - receivedArgs = args; - return current - initial; - }, - describe: "test mutation", - describeResult: "Success", - }); - - mutation.runAsPromise("first", 1); - mutation.runAsPromise("second", 2); - await mutation.runAsPromise("third", 3); - await delay(10); - - // Should use first args - assertEquals(receivedArgs, ["first", 1]); -}); diff --git a/test/react-button.test.tsx b/test/react-button.test.tsx deleted file mode 100644 index 2db95cfdf819fe4be3dbda542c42c0627037c325..0000000000000000000000000000000000000000 --- a/test/react-button.test.tsx +++ /dev/null @@ -1,724 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import type { FC } from "react"; -import { describe, expect, test, vi } from "vitest"; -import { MutationClient } from "../src/client.ts"; -import { createMutationButton, useMutate } from "../src/react.ts"; - -// Helper to create a test mutation client -function createTestClient() { - const errors: Array<{ message: string; error: unknown }> = []; - const successes: string[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return {}; - }, - reportError(message, error) { - errors.push({ message, error }); - }, - reportSuccess(message) { - successes.push(message); - }, - }); - - return { client, errors, successes }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// Test button component -interface TestButtonProps { - onClick?: (e: React.MouseEvent) => void; - isPending: boolean; - children: React.ReactNode; - disabled?: boolean; - variant?: "primary" | "secondary"; -} - -const TestButton: FC = ({ - onClick, - isPending, - children, - disabled, - variant, -}) => { - return ( - - ); -}; - -describe("createMutationButton - Basic Functionality", () => { - test("should create a mutation button component", () => { - const MutationButton = createMutationButton(TestButton); - expect(MutationButton).toBeDefined(); - expect(MutationButton.displayName).toBe("MutationButton[TestButton]"); - }); - - test("should execute mutation with static args", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy()); - - await waitFor(() => expect(screen.getByText("Click Me")).toBeTruthy()); - }); - - test("should execute mutation with dynamic args from function", async () => { - const { client } = createTestClient(); - const argsSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - argsSpy(value); - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - { - return [`dynamic-${Date.now()}`]; - }} - > - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(argsSpy).toHaveBeenCalled()); - expect(argsSpy.mock.calls[0][0]).toMatch(/^dynamic-/); - }); - - test("should forward custom props to base component", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - expect(button.getAttribute("data-variant")).toBe("primary"); - expect(button.hasAttribute("disabled")).toBe(true); - }); -}); - -describe("createMutationButton - onClick Behavior", () => { - test("should call custom onClick before mutation", async () => { - const { client } = createTestClient(); - const onClickSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - expect(onClickSpy).toHaveBeenCalled(); - }); - - test("should prevent mutation if onClick calls preventDefault", async () => { - const { client } = createTestClient(); - const mutateSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - mutateSpy(value); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - e.preventDefault()} - > - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await delay(50); - - // Mutation should NOT be called - expect(mutateSpy).not.toHaveBeenCalled(); - }); - - test("should prevent mutation if args function calls preventDefault", async () => { - const { client } = createTestClient(); - const mutateSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - mutateSpy(value); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - { - e.preventDefault(); - return ["test"]; - }} - > - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await delay(50); - - // Mutation should NOT be called - expect(mutateSpy).not.toHaveBeenCalled(); - }); - - test("should prevent mutation if args function returns null", async () => { - const { client } = createTestClient(); - const mutateSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - mutateSpy(value); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - { - // Conditional args - return null to prevent mutation - return Math.random() > 0.5 ? ["test"] : null; - }} - > - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - - // Click multiple times to test the conditional logic - await userEvent.click(button); - await userEvent.click(button); - await userEvent.click(button); - - await delay(50); - - // Mutation may or may not be called depending on random - // This test just ensures null args don't crash - }); -}); - -describe("createMutationButton - Callback Handlers", () => { - test("should call onSuccess callback on successful mutation", async () => { - const { client } = createTestClient(); - const onSuccessSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(onSuccessSpy).toHaveBeenCalled()); - expect(onSuccessSpy).toHaveBeenCalledWith("result-test"); - }); - - test("should call onError callback on failed mutation", async () => { - const { client } = createTestClient(); - const onErrorSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(onErrorSpy).toHaveBeenCalled()); - expect(onErrorSpy.mock.calls[0][0]).toBeInstanceOf(Error); - }); - - test("should call onSettled callback on success", async () => { - const { client } = createTestClient(); - const onSettledSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(onSettledSpy).toHaveBeenCalled()); - expect(onSettledSpy.mock.calls[0][0]).toEqual({ - status: "success", - result: "result-test", - }); - }); - - test("should call onSettled callback on error", async () => { - const { client } = createTestClient(); - const onSettledSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(onSettledSpy).toHaveBeenCalled()); - expect(onSettledSpy.mock.calls[0][0]).toMatchObject({ - status: "error", - }); - expect(onSettledSpy.mock.calls[0][0].error).toBeInstanceOf(Error); - }); - - test("should prevent global handlers when local handlers are provided", async () => { - const { client, errors, successes } = createTestClient(); - const onSuccessSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - describeResult: () => "Success message", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(onSuccessSpy).toHaveBeenCalled()); - - // Global success handler should still be called per documentation - // "Global event handlers will still be called!" - // This is actually testing current behavior - may need verification - }); -}); - -describe("createMutationButton - UseMutateResult Integration", () => { - test("should accept UseMutateResult instead of Mutation", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - const mutateResult = useMutate(mutation); - - return ( -
- - Click Me - - {mutateResult.isSuccess &&
Success!
} -
- ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy()); - }); - - test("should show isPending state from useMutate", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(50); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - const mutateResult = useMutate(mutation); - - return ( - - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy()); - - await waitFor(() => expect(screen.getByText("Click Me")).toBeTruthy(), { - timeout: 100, - }); - }); -}); - -describe("createMutationButton - Edge Cases", () => { - test("should handle rapid clicks", async () => { - const { client } = createTestClient(); - let callCount = 0; - let completedCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - callCount++; - await delay(10); - completedCount++; - return `result-${callCount}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - const { isPending } = useMutate(mutation); - return ( -
- - Click Me - -
{isPending ? "busy" : "idle"}
-
- ); - } - - render(); - - const button = screen.getByText("Click Me"); - - // Rapid clicks - await userEvent.click(button); - await userEvent.click(button); - await userEvent.click(button); - - // Wait for all mutations to complete and return to idle - await waitFor( - () => { - expect(completedCount).toBeGreaterThan(0); - expect(screen.getByTestId("pending-status").textContent).toBe("idle"); - }, - { timeout: 200 }, - ); - - // All clicks should be processed - expect(callCount).toBeGreaterThan(0); - }); - - test("should handle component unmount during mutation", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(50); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - - Click Me - - ); - } - - const { unmount } = render(); - - const button = screen.getByText("Click Me"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy()); - - // Unmount while mutation is pending - unmount(); - - // Should not throw or cause errors - await delay(100); - }); - - test("should handle args function throwing error", async () => { - const { client } = createTestClient(); - const mutateSpy = vi.fn(); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - const mutation = client.define({ - async mutate(value: string) { - mutateSpy(value); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - const MutationButton = createMutationButton(TestButton); - - function Component() { - return ( - { - throw new Error("Args error"); - }} - > - Click Me - - ); - } - - render(); - - const button = screen.getByText("Click Me"); - - // Click will trigger the error in args function - // React will catch it and log to console - try { - await userEvent.click(button); - } catch (e) { - // Error is expected - } - - // Mutation should NOT be called since args threw - expect(mutateSpy).not.toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); - }); -}); diff --git a/test/react.test.tsx b/test/react.test.tsx deleted file mode 100644 index 5ef485eaa1082da09389dcb247eb008ed95e6f0d..0000000000000000000000000000000000000000 --- a/test/react.test.tsx +++ /dev/null @@ -1,1230 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { render, screen, waitFor } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import { useState } from "react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { MutationClient } from "../src/client.ts"; -import { createMutationButton, useMutate } from "../src/react.ts"; -import type { Mutation } from "../src/types.ts"; - -// Helper to create a test mutation client -function createTestClient() { - const errors: Array<{ message: string; error: unknown }> = []; - const successes: string[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - setValue(key: string, value: string) { - testStore.set(key, value); - onRestore(() => testStore.delete(key)); - }, - }; - }, - reportError(message, error) { - errors.push({ message, error }); - }, - reportSuccess(message) { - successes.push(message); - }, - }); - - return { client, errors, successes }; -} - -const testStore = new Map(); - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe("Observer Class - Watched Set Mechanism", () => { - test("should only trigger re-render when watched properties change", async () => { - const { client } = createTestClient(); - let renderCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - renderCount++; - const { run, isPending } = useMutate(mutation); - // Only watching isPending, so changes to result/error shouldn't trigger re-render - return ( - - ); - } - - render(); - const initialRenders = renderCount; - - const button = screen.getByText("Click"); - await userEvent.click(button); - - // Should re-render when isPending becomes true - await waitFor(() => expect(screen.getByText("Loading...")).toBeTruthy()); - expect(renderCount).toBeGreaterThan(initialRenders); - - const rendersAfterPending = renderCount; - - // Should re-render when isPending becomes false - await waitFor(() => expect(screen.getByText("Click")).toBeTruthy()); - - // Should have exactly 2 more renders (pending true, pending false) - // NOT re-rendering for result/error changes since they're not watched - expect(renderCount).toBe(rendersAfterPending + 1); - - // Wait a bit more to ensure all async operations complete - await delay(10); - }); - - test("should track multiple watched properties independently", async () => { - const { client } = createTestClient(); - const watchedProperties = new Set(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const state = useMutate(mutation); - - // Access multiple properties - const { run, isPending, isSuccess, result } = state; - - return ( -
- -
Pending: {isPending.toString()}
-
Success: {isSuccess.toString()}
-
Result: {result ?? "none"}
-
- ); - } - - render(); - - // All accessed properties should cause re-renders - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy()); - - await waitFor(() => { - expect(screen.getByText("Success: true")).toBeTruthy(); - expect(screen.getByText("Result: result-test")).toBeTruthy(); - }); - - // Wait for all async operations to complete - await delay(10); - }); - - test("should not re-render when unwatched properties change", async () => { - const { client } = createTestClient(); - let renderCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - await delay(10); - if (value === "error") throw new Error("Test error"); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - renderCount++; - const { run } = useMutate(mutation); - // NOT watching isPending, isSuccess, result, isError, error - return ; - } - - render(); - const initialRenders = renderCount; - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await delay(50); - - // Should not have re-rendered since we're not watching any state - expect(renderCount).toBe(initialRenders); - }); -}); - -describe("Observer Class - State Deduplication", () => { - test("should not trigger re-render if state value hasn't changed", async () => { - const { client } = createTestClient(); - let renderCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - await delay(5); - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - renderCount++; - const { run, status } = useMutate(mutation); - return ( -
- -
Status: {status}
-
- ); - } - - render(); - const initialRenders = renderCount; - - // Status starts as "idle" - expect(screen.getByText("Status: idle")).toBeTruthy(); - - // Even if we force a state update with the same values, - // it shouldn't re-render - await delay(10); - - // Render count should still be initial - expect(renderCount).toBe(initialRenders); - }); -}); - -describe("Observer Class - Subscription Management", () => { - test("should unsubscribe when mutation changes", async () => { - const { client } = createTestClient(); - - const mutation1 = client.define({ - async mutate(value: string) { - await delay(10); - return `mut1-${value}`; - }, - describe: "mutation 1", - optimistic() {}, - }); - - const mutation2 = client.define({ - async mutate(value: string) { - await delay(10); - return `mut2-${value}`; - }, - describe: "mutation 2", - optimistic() {}, - }); - - function Component({ useMut1 }: { useMut1: boolean }) { - const { run, result, isSuccess } = useMutate(useMut1 ? mutation1 : mutation2); - return ( -
- - {isSuccess &&
Result: {result}
} -
- ); - } - - const { rerender } = render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Result: mut1-test")).toBeTruthy()); - - // Change to mutation2 - rerender(); - - // State should be reset - await waitFor(() => expect(screen.queryByText("Result: mut1-test")).toBeNull()); - - // Run mutation2 - await userEvent.click(button); - await waitFor(() => expect(screen.getByText("Result: mut2-test")).toBeTruthy()); - }); - - test("should unsubscribe when component unmounts", async () => { - const { client } = createTestClient(); - const unsubscribeSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(50); // Longer delay to ensure mutation is in progress - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - // Spy on the subscribe method - const originalSubscribe = mutation.subscribe.bind(mutation); - mutation.subscribe = (key, callback) => { - const unsub = originalSubscribe(key, callback); - return () => { - unsubscribeSpy(); - unsub(); - }; - }; - - function Component() { - const { run, isPending } = useMutate(mutation); - return ( - - ); - } - - const { unmount } = render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - // Wait for mutation to start - await waitFor(() => expect(screen.getByText("Loading")).toBeTruthy()); - - // Unmount while mutation is in progress - unmount(); - - // Unsubscribe should have been called - await waitFor(() => expect(unsubscribeSpy).toHaveBeenCalled()); - }); - - test("should handle key changes and resubscribe", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string, value: string) { - await delay(10); - return `${id}:${value}`; - }, - key: ({ args: [id] }) => id, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const [id, setId] = useState("key1"); - const { run, result, isSuccess } = useMutate(mutation); - - return ( -
- - - {isSuccess &&
Result: {result}
} -
- ); - } - - render(); - - // Run with key1 - const runButton = screen.getByText("Run key1"); - await userEvent.click(runButton); - - await waitFor(() => expect(screen.getByText("Result: key1:data")).toBeTruthy()); - - // Switch to key2 - const switchButton = screen.getByText("Switch Key"); - await userEvent.click(switchButton); - - // Run with key2 - const runButton2 = screen.getByText("Run key2"); - await userEvent.click(runButton2); - - await waitFor(() => expect(screen.getByText("Result: key2:data")).toBeTruthy()); - }); -}); - -describe("Observer Class - Error Message Computation", () => { - test("should compute error message with mutation description", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(itemId: string) { - throw new Error("Network timeout"); - }, - describe: (itemId: string) => `delete item ${itemId}`, - optimistic() {}, - }); - - function Component() { - const { run, errorMessage, isError } = useMutate(mutation); - return ( -
- - {isError &&
Error: {errorMessage}
} -
- ); - } - - const { container } = render(); - - const button = screen.getByText("Delete"); - await userEvent.click(button); - - await waitFor(() => { - const errorDiv = screen.getByTestId("error-display"); - expect(errorDiv).toBeTruthy(); - // Should contain the error message - expect(container.textContent).toContain("Network timeout"); - }); - }); - - test("should handle error message when mutation is null", async () => { - const { client } = createTestClient(); - - function Component() { - const { setError, errorMessage, isError } = useMutate(null); - return ( -
- - {isError &&
Error: {errorMessage}
} -
- ); - } - - render(); - - const button = screen.getByText("Set Error"); - await userEvent.click(button); - - await waitFor(() => { - expect(screen.getByText("Error: Manual error")).toBeTruthy(); - }); - }); -}); - -describe("useMutate Hook - Basic Usage", () => { - test("should return initial idle state", () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const state = useMutate(mutation); - return ( -
-
Status: {state.status}
-
IsPending: {state.isPending.toString()}
-
IsSuccess: {state.isSuccess.toString()}
-
IsError: {state.isError.toString()}
-
IsMutating: {state.isMutating.toString()}
-
IsOptimisticData: {state.isOptimisticData.toString()}
-
- ); - } - - render(); - - expect(screen.getByText("Status: idle")).toBeTruthy(); - expect(screen.getByText("IsPending: false")).toBeTruthy(); - expect(screen.getByText("IsSuccess: false")).toBeTruthy(); - expect(screen.getByText("IsError: false")).toBeTruthy(); - expect(screen.getByText("IsMutating: false")).toBeTruthy(); - expect(screen.getByText("IsOptimisticData: false")).toBeTruthy(); - }); - - test("should handle null mutation", () => { - function Component() { - const { run, status } = useMutate(null); - return ( -
- -
Status: {status}
-
- ); - } - - render(); - - expect(screen.getByText("Status: idle")).toBeTruthy(); - - // Clicking should not throw - const button = screen.getByText("Run"); - expect(() => userEvent.click(button)).not.toThrow(); - }); - - test("should transition through states correctly", async () => { - const { client } = createTestClient(); - const states: string[] = []; - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, status, isPending, isSuccess } = useMutate(mutation); - - states.push(status); - - return ( -
- -
Status: {status}
-
Pending: {isPending.toString()}
- {isSuccess &&
Success!
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - // Should go to mutating - await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy()); - - // Should complete to success - await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy()); - - // States should include: idle -> mutating -> success - expect(states).toContain("idle"); - expect(states).toContain("mutating"); - expect(states).toContain("success"); - }); -}); - -describe("useMutate Hook - Error Handling", () => { - test("should handle errors locally when error properties are watched", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { run, errorMessage, isError } = useMutate(mutation); - return ( -
- - {isError &&
Local Error: {errorMessage}
} -
- ); - } - - const { container } = render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - // Error should be displayed locally - await waitFor(() => { - expect(screen.getByTestId("local-error")).toBeTruthy(); - expect(container.textContent).toContain("Test error"); - }); - - // Global error handler should NOT be called - expect(errors.length).toBe(0); - }); - - test("should call global error handler when error is not watched", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { run, isPending } = useMutate(mutation); - // NOT watching error, errorMessage, or isError - return ( - - ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await delay(50); - - // Global error handler SHOULD be called - await waitFor(() => expect(errors.length).toBe(1)); - expect(errors[0].message).toContain("Failed to failing mutation"); - }); - - test("should handle watching only error property (not errorMessage)", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { run, error } = useMutate(mutation); - // Only watching error, not errorMessage or isError - return ( -
- - {error &&
Has Error
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Has Error")).toBeTruthy()); - - // Should NOT call global handler since error is watched - expect(errors.length).toBe(0); - }); -}); - -describe("useMutate Hook - Success Handling", () => { - test("should handle success locally when result is watched", async () => { - const { client, successes } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - describeResult: () => "Operation succeeded", - optimistic() {}, - }); - - function Component() { - const { run, result, isSuccess } = useMutate(mutation); - return ( -
- - {isSuccess &&
Result: {result}
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy()); - - // Global success handler should NOT be called - expect(successes.length).toBe(0); - }); - - test("should call global success handler when result is not watched", async () => { - const { client, successes } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - describeResult: () => "Operation succeeded", - optimistic() {}, - }); - - function Component() { - const { run, isPending } = useMutate(mutation); - // NOT watching result or isSuccess - return ( - - ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await delay(50); - - // Global success handler SHOULD be called - await waitFor(() => expect(successes.length).toBe(1)); - expect(successes[0]).toBe("Operation succeeded"); - }); - - test("should handle watching isSuccess without result", async () => { - const { client, successes } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - describeResult: () => "Operation succeeded", - optimistic() {}, - }); - - function Component() { - const { run, isSuccess } = useMutate(mutation); - // Only watching isSuccess, not result - return ( -
- - {isSuccess &&
Success!
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy()); - - // Should NOT call global handler since isSuccess is watched - expect(successes.length).toBe(0); - }); -}); - -describe("useMutate Hook - clear() Method", () => { - test("should clear success state", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, clear, result, isSuccess, status } = useMutate(mutation); - return ( -
- - -
Status: {status}
- {isSuccess &&
Result: {result}
} -
- ); - } - - render(); - - const runButton = screen.getByText("Run"); - await userEvent.click(runButton); - - await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy()); - expect(screen.getByText("Status: success")).toBeTruthy(); - - const clearButton = screen.getByText("Clear"); - await userEvent.click(clearButton); - - await waitFor(() => { - expect(screen.queryByText("Result: result-test")).toBeNull(); - expect(screen.getByText("Status: idle")).toBeTruthy(); - }); - }); - - test("should clear error state", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { run, clear, errorMessage, isError, status } = useMutate(mutation); - return ( -
- - -
Status: {status}
- {isError &&
Error: {errorMessage}
} -
- ); - } - - const { container } = render(); - - const runButton = screen.getByText("Run"); - await userEvent.click(runButton); - - await waitFor(() => { - expect(screen.getByTestId("error-message")).toBeTruthy(); - expect(container.textContent).toContain("Test error"); - }); - expect(screen.getByText("Status: error")).toBeTruthy(); - - const clearButton = screen.getByText("Clear"); - await userEvent.click(clearButton); - - await waitFor(() => { - expect(screen.queryByTestId("error-message")).toBeNull(); - expect(screen.getByText("Status: idle")).toBeTruthy(); - }); - }); - - test("should not affect mutating state when calling clear", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(50); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, clear, status, isPending } = useMutate(mutation); - return ( -
- - -
Status: {status}
-
Pending: {isPending.toString()}
-
- ); - } - - render(); - - const runButton = screen.getByText("Run"); - await userEvent.click(runButton); - - await waitFor(() => expect(screen.getByText("Status: mutating")).toBeTruthy()); - - const clearButton = screen.getByText("Clear"); - await userEvent.click(clearButton); - - // Status should still be mutating - expect(screen.getByText("Status: mutating")).toBeTruthy(); - expect(screen.getByText("Pending: true")).toBeTruthy(); - }); -}); - -describe("useMutate Hook - setError() Method", () => { - test("should set error state manually", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { setError, errorMessage, isError, status } = useMutate(mutation); - return ( -
- -
Status: {status}
- {isError &&
Error: {errorMessage}
} -
- ); - } - - render(); - - expect(screen.getByText("Status: idle")).toBeTruthy(); - - const button = screen.getByText("Set Error"); - await userEvent.click(button); - - await waitFor(() => { - expect(screen.getByText("Status: error")).toBeTruthy(); - expect(screen.getByText("Error: Manual error")).toBeTruthy(); - }); - }); - - test("should clear success state when setting error", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, setError, result, isSuccess, isError, errorMessage } = useMutate(mutation); - return ( -
- - - {isSuccess &&
Result: {result}
} - {isError &&
Error: {errorMessage}
} -
- ); - } - - render(); - - const runButton = screen.getByText("Run"); - await userEvent.click(runButton); - - await waitFor(() => expect(screen.getByText("Result: result-test")).toBeTruthy()); - - const setErrorButton = screen.getByText("Set Error"); - await userEvent.click(setErrorButton); - - await waitFor(() => { - expect(screen.queryByText("Result: result-test")).toBeNull(); - expect(screen.getByText("Error: Manual error")).toBeTruthy(); - }); - }); -}); - -describe("useMutate Hook - runWithOptions", () => { - test("should support onSuccess callback", async () => { - const { client } = createTestClient(); - const onSuccessSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { runWithOptions, isSuccess } = useMutate(mutation); - return ( -
- - {isSuccess &&
Success!
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Success!")).toBeTruthy()); - expect(onSuccessSpy).toHaveBeenCalledWith("result-test"); - }); - - test("should support onError callback", async () => { - const { client } = createTestClient(); - const onErrorSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { runWithOptions, isError } = useMutate(mutation); - return ( -
- - {isError &&
Error!
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Error!")).toBeTruthy()); - expect(onErrorSpy).toHaveBeenCalled(); - expect(onErrorSpy.mock.calls[0][0]).toBeInstanceOf(Error); - }); - - test("should support onSettled callback for success", async () => { - const { client } = createTestClient(); - const onSettledSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { runWithOptions } = useMutate(mutation); - return ( - - ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await delay(50); - - expect(onSettledSpy).toHaveBeenCalled(); - }); - - test("should support onSettled callback for error", async () => { - const { client } = createTestClient(); - const onSettledSpy = vi.fn(); - - const mutation = client.define({ - async mutate(value: string) { - throw new Error("Test error"); - }, - describe: "failing mutation", - optimistic() {}, - }); - - function Component() { - const { runWithOptions } = useMutate(mutation); - return ( - - ); - } - - render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await delay(50); - - expect(onSettledSpy).toHaveBeenCalled(); - }); -}); - -describe("useMutate Hook - Edge Cases", () => { - test("should handle mutation changing during pending state", async () => { - const { client } = createTestClient(); - - const mutation1 = client.define({ - async mutate(value: string) { - await delay(100); - return `mut1-${value}`; - }, - describe: "mutation 1", - optimistic() {}, - }); - - const mutation2 = client.define({ - async mutate(value: string) { - await delay(10); - return `mut2-${value}`; - }, - describe: "mutation 2", - optimistic() {}, - }); - - function Component({ useMut1 }: { useMut1: boolean }) { - const { run, result, isSuccess, isPending } = useMutate(useMut1 ? mutation1 : mutation2); - return ( -
- -
Pending: {isPending.toString()}
- {isSuccess &&
Result: {result}
} -
- ); - } - - const { rerender } = render(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Pending: true")).toBeTruthy()); - - // Switch mutations while first is pending - rerender(); - - // State should reset - await waitFor(() => expect(screen.getByText("Pending: false")).toBeTruthy()); - - // Run mutation2 - await userEvent.click(button); - - await waitFor(() => expect(screen.getByText("Result: mut2-test")).toBeTruthy()); - }); - - test("should handle multiple sequential calls with same key", async () => { - const { client } = createTestClient(); - let callCount = 0; - - const mutation = client.define({ - async mutate(value: string) { - callCount++; - await delay(10); - return `result-${callCount}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, result, isSuccess } = useMutate(mutation); - return ( -
- - {isSuccess &&
Result: {result}
} -
- ); - } - - render(); - - const button = screen.getByText("Run"); - - // Click multiple times - await userEvent.click(button); - await userEvent.click(button); - await userEvent.click(button); - - // Should see the last result - await waitFor(() => expect(screen.getByText(/Result: result-/)).toBeTruthy()); - }); - - test("should handle args property correctly", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(id: string, name: string) { - await delay(10); - return `${id}:${name}`; - }, - describe: "test mutation", - optimistic() {}, - }); - - function Component() { - const { run, args, isPending } = useMutate(mutation); - return ( -
- -
Args: {args ? JSON.stringify(args) : "none"}
-
Pending: {isPending.toString()}
-
- ); - } - - render(); - - expect(screen.getByText("Args: none")).toBeTruthy(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - // Args should be set during mutation - await waitFor(() => { - const argsText = screen.getByText(/Args: \[/); - expect(argsText).toBeTruthy(); - }); - - // Args should be cleared after success - await waitFor(() => expect(screen.getByText("Pending: false")).toBeTruthy()); - }); -}); - -describe("useMutate Hook - Optimistic Data Flag", () => { - test("should set isOptimisticData during mutation states", async () => { - const { client } = createTestClient(); - - const mutation = client.define({ - async mutate(value: string) { - await delay(20); - return `result-${value}`; - }, - describe: "test mutation", - optimistic({ helpers }, value: string) { - helpers.setValue("test-key", value); - }, - }); - - function Component() { - const { run, isOptimisticData, status } = useMutate(mutation); - return ( -
- -
Optimistic: {isOptimisticData.toString()}
-
Status: {status}
-
- ); - } - - render(); - - expect(screen.getByText("Optimistic: false")).toBeTruthy(); - - const button = screen.getByText("Run"); - await userEvent.click(button); - - // Should be true during mutation - await waitFor(() => expect(screen.getByText("Optimistic: true")).toBeTruthy()); - - // Should be false after completion - await waitFor(() => expect(screen.getByText("Status: success")).toBeTruthy()); - await waitFor(() => expect(screen.getByText("Optimistic: false")).toBeTruthy()); - }); -});