From c6028dcd497631ff2d5a5a6c24b74b41f3ad21f4 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 28 Jan 2026 17:08:11 -0800 Subject: [PATCH] feat: rc 3 --- example/src/App.tsx | 4 + jsr.json | 2 +- readme.md | 76 ++++++- src/{queued.ts => blocking.ts} | 78 ++++--- src/client.ts | 28 ++- src/{batch.ts => debounced.ts} | 190 +++++++++++----- src/mod.ts | 16 +- src/{react.tsx => react.ts} | 213 +++++++++++------- test/{queued.test.ts => blocking.test.ts} | 125 ++++++---- test/{batch.test.ts => debounced.test.ts} | 97 +++++--- ...ath-types.test.ts => object-path.types.ts} | 0 11 files changed, 563 insertions(+), 266 deletions(-) rename src/{queued.ts => blocking.ts} (82%) rename src/{batch.ts => debounced.ts} (73%) rename src/{react.tsx => react.ts} (66%) rename test/{queued.test.ts => blocking.test.ts} (83%) rename test/{batch.test.ts => debounced.test.ts} (89%) rename test/{object-path-types.test.ts => object-path.types.ts} (100%) diff --git a/example/src/App.tsx b/example/src/App.tsx index 67e13035b581a3437a2c1990f5ee109fd7927e24..ab99be9c72a92bdbc8f0e704c08f507b005dfc6d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -69,6 +69,10 @@ const mutIncrement = mutationClient.defineDebounced({ }, describe: "update counter", + describeResult: ({ initial, current }) => { + const delta = current - initial; + return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`; + }, }); function CustomButton( diff --git a/jsr.json b/jsr.json index 85a7159f32565ad14a17132bd0e62a719b72f355..89cf8b9a0e8c3bc7d4c131025d5dee86efc3c478 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/readme.md b/readme.md index f86612a2c934f2ae0b5f7ffa0a95361d64e7bf6a..b1ed3256e5fed7486aec6b2c64c661190256b584 100644 --- a/readme.md +++ b/readme.md @@ -15,7 +15,7 @@ The primary gains React Mutation provides are display a UI toast. Otherwise, the component can display the error locally. - Optimistic helpers allow defining rollbacks and refetching logic independant of the actual mutation. The [built in helpers for React Query](#React-Query-Optimistic-Helpers) show this power in more detail. -- Batched Mutations are just so awesome to use. +- Debounced Mutations are just so awesome to use. ## Usage @@ -79,7 +79,7 @@ const queryItemList = queryOptions({ ... }); const queryItem = (id: string) => queryOptions({ ... }); // The convention is to name handlers starting with `mut` -const mutDeleteItem = mutations.defineQueued({ +const mutDeleteItem = mutations.defineBlocking({ // `mutate` comes first, is only worried about syncing with the backend. async mutate(id: string) { const response = await fetch(`/items/${id}`, { method: "delete" }); @@ -122,7 +122,7 @@ export function Example({ id }: { id: string }) { ### Debounced Mutations -A debounced mutation is defined with `mutations.defineBatched`. +A debounced mutation is defined with `mutations.defineDebounced`. ```tsx const mutSetItemName = mutationClient.defineDebounced({ @@ -178,4 +178,72 @@ function Item({ id }: { id: string }) { } ``` -### +### Optimistic Updates + +The `optimistic` function is given an object with the following APIs + +- All values from `MutationClient`'s `context`, spread. With React Query this is `get` and `client`. +- `helpers` - is the return type of `getOptimisticHelpers` (see next section) +- `args` - which is the arguments passed to the mutator +- `onSuccess` - add a callback to update queries after a success +- `onRestore` - add a callback to revert your optimistic update +- `onRefetch` - add a callback to fetch data after a success + +### React Query Optimistic Helpers + +When using React Query, you can opt into some incredible helpers for making it +very easy to write Optimistic Updates. Our setup at work is with this client +configuration. + +```ts +import { MutationClient } from "@clo/react-mutation"; +import { + boundQueryClientGet, + queryClientOptimisticHelpers, +} from "@clo/react-mutation/tanstack-query.ts"; +import { isServer } from "@tanstack/react-query"; +import { getQueryClient, makeNewQueryClient } from "./react-query-client"; + +const client = isServer ? makeNewQueryClient() : getQueryClient(); +export const mutations = new MutationClient({ + enabled: !isServer, + context: { + client, + get: boundQueryClientGet(client), + }, + getOptimisticHelpers: queryClientOptimisticHelpers(client), + reportError(message) { + showAlert(message, "error"); + }, + reportSuccess(message: string) { + showAlert(message, "success"); + }, +}); +``` + +Within optimistic updates, a `helpers` object is provided with many useful +helper functions. All helper functions take a `QueryKeyAndFn` (return type of +TanStack Query's `queryOptions`), and will track every query touched to +automatically implement `onRefetch` and `onRestore` callbacks. The current list of them is: + +- `set` - overwrite an entire query +- `updateExisting` - overwrite an entire query only if it exists +- `removeQuery` - delete a query, but restore and refetch when rolled back. +- For queries that resolve to arrays: + - `arrayPush` - add items to the end + - `arrayUnshift` - add items to the start + - `arrayRemove` - remove items by a `filter` function + - `arrayUpdate` - update items by a `filter` + `update` function + - `arrayInsertIndex` - insert an item at an index +- **experimental**: Queries that are complex options. Each function takes a type-safe + json path to evaluate, but this system has type bugs. + - `objSet` - set a property + - `objSetMany` - set many properties at once + - `objIncrement` - increment a number + - `objDecrement` - decrement a number + - `objToggle` - toggle a boolean + - `objArrayPush` - add items to the end of an array + - `objArrayUnshift` - add items to the start of an array + - `objArrayRemove` - remove items from array by `filter` + - `objArrayUpdate` - update items in array by `filter` + `update` + - `objArrayInsertIndex` - insert an item in an array at an index diff --git a/src/queued.ts b/src/blocking.ts similarity index 82% rename from src/queued.ts rename to src/blocking.ts index 992c3046121cd25533b87238041948f16526cf20..0c06e3e148c1f4a64f0bbe2892999e1f65beaa5a 100644 --- a/src/queued.ts +++ b/src/blocking.ts @@ -4,12 +4,12 @@ import type { Mutation, MutationEvent } from "./types.ts"; import { message as errMessage } from "@clo/lib/error.ts"; /** - * Argument to `defineMutation`. + * 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< +export interface BlockingMutationOptions< Args extends unknown[], Result, Config extends MutationClientConfig, @@ -33,14 +33,13 @@ export interface MutationOptions< /** * Used in success messages. * Phrase it as a complete success message, e.g., "Deleted item successfully" - * Set to null to suppress success reporting. */ - describeResult?: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null; + describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string); /** * 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; + optimistic: (context: BlockingOptimisticContext) => void; /** * Refetch all of the data this mutation could have affected. * Normally, optimistic helpers will perform @@ -60,7 +59,7 @@ export interface MutationOptions< key?: (context: Config["context"] & { args: Args }) => string | string[]; } -export type OptimisticContext< +export type BlockingOptimisticContext< Args extends unknown[], Result, Config extends MutationClientConfig, @@ -73,12 +72,14 @@ export type OptimisticContext< onSuccess: (cb: (result: Result) => void) => void; }; -interface Channel { +interface BlockingChannel { listeners: Set<(update: MutationEvent) => void>; status: "idle" | "mutating" | "refetching"; rollbacks: Array<() => void>; refetches: Array<() => Promise>; queue: Array>; + // Shared optimistic helpers instance for the channel + helpers: OptimisticHelpers | null; } interface Item { @@ -89,19 +90,19 @@ interface Item { reject: (error: unknown) => void; } -export class QueuedMutation< +export class BlockingMutation< Args extends unknown[], Result, Config extends MutationClientConfig, > implements Mutation { - #options: MutationOptions; + #options: BlockingMutationOptions; #client: MutationClientFromConfig; - #queues: Map> = new Map(); + #channels: Map> = new Map(); client: MutationClientFromConfig; constructor( client: MutationClient, - options: MutationOptions, + options: BlockingMutationOptions, ) { this.#options = options; this.#client = client; @@ -115,7 +116,7 @@ export class QueuedMutation< } #getOrPutChannel(key: string) { - let channel = this.#queues.get(key); + let channel = this.#channels.get(key); if (!channel) { const rollbacks: Array<() => []> = []; channel = { @@ -124,8 +125,9 @@ export class QueuedMutation< rollbacks, refetches: [], queue: [], + helpers: null, }; - this.#queues.set(key, channel); + this.#channels.set(key, channel); } return channel; } @@ -140,7 +142,7 @@ export class QueuedMutation< } #notify( - channel: Channel, + channel: BlockingChannel, status: MutationEvent["status"], result: Result | null = null, error: unknown = null, @@ -149,14 +151,14 @@ export class QueuedMutation< channel.listeners.forEach((cb) => cb(event)); } - #setIdle(key: string, channel: Channel) { + #setIdle(key: string, channel: BlockingChannel) { 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) { - this.#queues.delete(key); + this.#channels.delete(key); } } @@ -167,9 +169,8 @@ export class QueuedMutation< : describe; } - describeResult(args: Args, result: Result): string | undefined { + describeResult(args: Args, result: Result): string { const { describeResult } = this.#options; - if (describeResult === null || describeResult === undefined) return undefined; return typeof describeResult === "function" ? describeResult({ ...this.#client.context, args, result }) : describeResult; @@ -177,6 +178,11 @@ export class QueuedMutation< /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ run(...args: Args) { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } this.runAndReturn(...args).then((result) => { const message = this.describeResult(args, result); if (message && this.#client.reportSuccess) { @@ -190,9 +196,28 @@ export class QueuedMutation< /** Calls the mutation, treating the errors as promise rejection. */ runAndReturn(...args: Args): 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); + // 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; @@ -205,22 +230,11 @@ export class QueuedMutation< channel.rollbacks.push(cb); rollbacks += 1; }; - const onRefetch = (cb: () => Promise) => { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }; try { this.#options.optimistic({ args, - helpers: this.#client.getOptimisticHelpers({ - onRestore, - onRefetch, - }), + helpers: channel.helpers, onRestore, onSuccess(cb) { if (expired) { @@ -260,7 +274,7 @@ export class QueuedMutation< return promise; } - #executeNext(key: string, channel: Channel) { + #executeNext(key: string, channel: BlockingChannel) { const item = channel.queue.shift(); if (!item) { this.#setIdle(key, channel); @@ -309,7 +323,7 @@ export class QueuedMutation< let next; while (next = channel.rollbacks.pop()) next(); - // Cancel all remaining items in the queue + // Cancel all remaining items in the channel const remainingItems = channel.queue.splice(0); remainingItems.forEach((queuedItem) => { queuedItem.reject(error); diff --git a/src/client.ts b/src/client.ts index edda210b0d76bb76be7a0daf48e76fafdc7fbedf..1d7a8394d7e5bc911aa6a7579a40021b1a760c75 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ -import { BatchMutation, type BatchMutationOptions } from "./batch.ts"; -import { type MutationOptions, QueuedMutation } from "./queued.ts"; +import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; +import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts"; import type { Mutation } from "./types.ts"; export interface MutationClientConfig { @@ -24,11 +24,17 @@ export interface MutationClientOptions< reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; /** - * Compare two values for deep equality. Used by BatchMutation to determine + * Compare two values for deep equality. Used by DebouncedMutation to determine * if the optimistic state has changed from the initial snapshot. * @default JSON.stringify based comparison */ deepEquals?: (a: unknown, b: unknown) => boolean; + /** + * When false, all mutation run functions will throw an error. + * Useful for preventing mutations during SSR. + * @default true + */ + enabled?: boolean; } export interface OptimisticEvents { @@ -45,6 +51,7 @@ export class MutationClient< reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; deepEquals: (a: unknown, b: unknown) => boolean; + enabled: boolean; constructor(options: MutationClientOptions) { this.context = options.context; @@ -52,21 +59,22 @@ export class MutationClient< this.reportError = options.reportError; this.reportSuccess = options.reportSuccess; this.deepEquals = options.deepEquals ?? defaultDeepEquals; + this.enabled = options.enabled ?? true; } /** - * Define a queued mutation. A mutation blocks the UI until it is complete. + * Define a blocking mutation. A mutation blocks the UI until it is complete. * You press a button, a pending state appears, then it completes. This works * great for forms, and is similar to React Query's mutation system. */ defineBlocking( - options: MutationOptions< + options: BlockingMutationOptions< Args, Result, { context: Context; optimisticHelpers: OptimisticHelpers } >, ): Mutation { - return new QueuedMutation< + return new BlockingMutation< Args, Result, { context: Context; optimisticHelpers: OptimisticHelpers } @@ -74,21 +82,21 @@ export class MutationClient< } /** - * Define a batched mutation. Each call to the mutation applies new optimistic + * Define a debounced 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 batches. This + * 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. */ defineDebounced( - options: BatchMutationOptions< + options: DebouncedMutationOptions< Args, Result, Optimistic, { context: Context; optimisticHelpers: OptimisticHelpers } >, ): Mutation { - return new BatchMutation< + return new DebouncedMutation< Args, Result, Optimistic, diff --git a/src/batch.ts b/src/debounced.ts similarity index 73% rename from src/batch.ts rename to src/debounced.ts index 1e1e6540bff9a89dd1e21a903a13c5a05b12c2d8..fb8869a3826b8ccfc080e1d7a01c7ae20ed92c33 100644 --- a/src/batch.ts +++ b/src/debounced.ts @@ -3,7 +3,7 @@ import type { MutationClientConfig } from "./client.ts"; import type { Mutation, MutationEvent } from "./types.ts"; import { message as errMessage } from "@clo/lib/error.ts"; -export interface BatchMutationOptions< +export interface DebouncedMutationOptions< Args extends unknown[], Result, Optimistic, @@ -14,7 +14,7 @@ export interface BatchMutationOptions< * 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: BatchOptimisticContext, ...args: Args) => void; + 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 @@ -22,7 +22,7 @@ export interface BatchMutationOptions< * * Don't snapshot unrelated state that this mutation isn't concerned with. */ - getValue: (context: Config["context"], ...args: Args) => Optimistic; + getValue: (context: Config["context"] & { args: Args }) => Optimistic; /** * @default "debounce" @@ -34,7 +34,7 @@ export interface BatchMutationOptions< */ time?: number; - /** A key to associate batch items. For example, returning a user ID */ + /** A key to associate debounced items. For example, returning a user ID */ key: ( context: Config["context"] & { args: NoInfer }, ) => string | string[]; @@ -43,7 +43,7 @@ export interface BatchMutationOptions< * Commit the optimistic state. Throw on failure. */ commit: ( - context: BatchCommitContext, Optimistic, Config>, + context: DebouncedCommitContext, Optimistic, Config>, ) => Promise; /** * Used in error messages and debug tools. @@ -52,26 +52,26 @@ export interface BatchMutationOptions< describe: | string | (( - context: BatchCommitContext, Optimistic, Config>, + context: DebouncedCommitContext, Optimistic, Config>, ) => string); /** * Used in success messages. * Phrase it as a complete success message, e.g., "Renamed item successfully" - * Set to null to suppress success reporting. */ - describeResult?: + describeResult: | string | (( - context: BatchCommitContext, Optimistic, Config> & { result: Result }, - ) => string) - | null; + context: DebouncedCommitContext, Optimistic, Config> & { + result: Result; + }, + ) => string); /** * Refetch all of the data this mutation could have affected. */ refetch?: () => Promise; } -export type BatchOptimisticContext = +export type DebouncedOptimisticContext = & Config["context"] & { /** Add an event listener to roll back the update */ @@ -79,7 +79,7 @@ export type BatchOptimisticContext = helpers: Config["optimisticHelpers"]; }; -export type BatchCommitContext< +export type DebouncedCommitContext< Args, Optimistic, Config extends MutationClientConfig, @@ -92,13 +92,18 @@ export type BatchCommitContext< current: Optimistic; }; -interface BatchChannel { +interface DebouncedChannel< + Args extends unknown[], + Result, + Optimistic, + OptimisticHelpers, +> { listeners: Set<(update: MutationEvent) => void>; status: "idle" | "waiting" | "mutating" | "refetching"; - // Snapshot before first call in current batch + // Snapshot before first call in current debounced run initial: Optimistic | null; - // First args in batch (used for commit/describe/getValue) + // First args in debounced run (used for commit/describe/getValue) firstArgs: Args | null; rollbacks: Array<() => void>; refetches: Array<() => Promise>; @@ -107,7 +112,10 @@ interface BatchChannel { // Track last commit time for throttle mode lastCommitTime: number; - // Pending promises from callers in current batch + // 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; @@ -116,20 +124,23 @@ interface BatchChannel { }>; } -export class BatchMutation< +export class DebouncedMutation< Args extends unknown[], Result, Optimistic, Config extends MutationClientConfig, > implements Mutation { - #options: BatchMutationOptions; + #options: DebouncedMutationOptions; #client: MutationClientFromConfig; - #channels: Map> = new Map(); + #channels: Map< + string, + DebouncedChannel + > = new Map(); client: MutationClientFromConfig; constructor( client: MutationClient, - options: BatchMutationOptions, + options: DebouncedMutationOptions, ) { this.#options = options; this.#client = client; @@ -141,7 +152,9 @@ export class BatchMutation< return JSON.stringify(k); } - #getOrPutChannel(key: string): BatchChannel { + #getOrPutChannel( + key: string, + ): DebouncedChannel { let channel = this.#channels.get(key); if (!channel) { channel = { @@ -153,6 +166,7 @@ export class BatchMutation< refetches: [], timer: null, lastCommitTime: 0, + helpers: null, pending: [], }; this.#channels.set(key, channel); @@ -170,7 +184,12 @@ export class BatchMutation< } #notify( - channel: BatchChannel, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, status: MutationEvent["status"], result: Result | null = null, error: unknown = null, @@ -179,7 +198,15 @@ export class BatchMutation< channel.listeners.forEach((cb) => cb(event)); } - #setIdle(key: string, channel: BatchChannel) { + #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 @@ -188,11 +215,19 @@ export class BatchMutation< } } - #resetBatchState(channel: BatchChannel) { + #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); @@ -215,12 +250,16 @@ export class BatchMutation< return describe; } - // Not available for batched mutations - success reporting happens during commit + // Not available for debounced mutations - success reporting happens during commit describeResult: undefined = undefined; - #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { + #describeResult( + args: Args, + initial: Optimistic, + current: Optimistic, + result: Result, + ): string { const { describeResult } = this.#options; - if (describeResult === null || describeResult === undefined) return undefined; return typeof describeResult === "function" ? describeResult({ ...this.#client.context, @@ -234,14 +273,26 @@ export class BatchMutation< /** 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).catch((error) => { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + const message = `Failed to ${this.describe(...args)}: ${ + errMessage(error) + }`; this.#client.reportError(message, error); }); } /** Calls the mutation, treating the errors as promise rejection. */ runAndReturn(...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); } @@ -249,10 +300,23 @@ export class BatchMutation< const key = this.key(args); const channel = this.#getOrPutChannel(key); - // If this is the first call in the batch, take a snapshot + // 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.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 @@ -265,35 +329,23 @@ export class BatchMutation< } channel.rollbacks.push(cb); }; - const onRefetch = (cb: () => Promise) => { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }; try { this.#options.optimistic( { ...this.#client.context, onRestore, - helpers: this.#client.getOptimisticHelpers({ - onRestore, - onRefetch, - }), + helpers: channel.helpers!, }, ...args, ); } catch (error) { expired = true; - // Rollback just this call's rollbacks // 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.#resetBatchState(channel); + this.#resetDebouncedState(channel); return Promise.reject(error); } expired = true; @@ -316,7 +368,12 @@ export class BatchMutation< #scheduleCommit( key: string, - channel: BatchChannel, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, ) { const time = this.#options.time ?? 200; @@ -346,7 +403,15 @@ export class BatchMutation< } } - #commit(key: string, channel: BatchChannel) { + #commit( + key: string, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + ) { // Clear timer if (channel.timer !== null) { clearTimeout(channel.timer); @@ -366,16 +431,16 @@ export class BatchMutation< const refetchCallbacks = [...channel.refetches]; // Get current snapshot - const current = this.#options.getValue( - this.#client.context, - ...firstArgs, - ); + 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.#resetBatchState(channel); + this.#resetDebouncedState(channel); this.#setIdle(key, channel); return; } @@ -384,7 +449,7 @@ export class BatchMutation< channel.status = "mutating"; this.#notify(channel, "mutating"); - // Clear batch state before async operation (but keep rollbacks/refetches for error case) + // Clear debounced state before async operation (but keep rollbacks/refetches for error case) channel.initial = null; channel.firstArgs = null; channel.pending = []; @@ -405,9 +470,16 @@ export class BatchMutation< pendingItems.forEach(({ resolve }) => resolve(result)); // Report success globally if any of the pending items requested it - const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); + const shouldReportSuccess = pendingItems.some((item) => + item.reportSuccessGlobally + ); if (shouldReportSuccess) { - const message = this.#describeResult(firstArgs, initial, current, result); + const message = this.#describeResult( + firstArgs, + initial, + current, + result, + ); if (message && this.#client.reportSuccess) { this.#client.reportSuccess(message); } @@ -427,7 +499,9 @@ export class BatchMutation< // 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)}`; + const message = `Failed to refetch after ${ + this.describe(...firstArgs) + }: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); @@ -466,7 +540,9 @@ export class BatchMutation< // 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)}`; + const message = `Failed to refetch after ${ + this.describe(...firstArgs) + }: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); diff --git a/src/mod.ts b/src/mod.ts index 37cd2172f93dfefafdc97234dc9726ddd3f87201..df9eccb473ce97cc4fb3e9d044bf729185bfb76d 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,12 @@ -export type { MutationOptions, OptimisticContext } from "./queued.ts"; export type { - BatchCommitContext, - BatchMutationOptions, - BatchOptimisticContext, -} from "./batch.ts"; + BlockingMutationOptions, + BlockingOptimisticContext, +} from "./blocking.ts"; +export type { + DebouncedCommitContext, + DebouncedMutationOptions, + DebouncedOptimisticContext, +} from "./debounced.ts"; export { MutationClient, type MutationClientConfig, @@ -13,6 +16,7 @@ export { export type { Mutation, MutationEvent } from "./types.ts"; export { createMutationButton, + type MutationButtonComponent, type MutationButtonProps, useMutate, type UseMutateError, @@ -20,4 +24,4 @@ export { type UseMutateResult, type UseMutateResultBase, type UseMutateSuccess, -} from "./react.tsx"; +} from "./react.ts"; diff --git a/src/react.tsx b/src/react.ts similarity index 66% rename from src/react.tsx rename to src/react.ts index 278ad496f818ef6efe35dca6b6bd262153191c1e..528511caae7f100e7d60cdf60b3ddd2ca2c23796 100644 --- a/src/react.tsx +++ b/src/react.ts @@ -9,6 +9,7 @@ import { } from "react"; import { message as errMessage } from "@clo/lib/error.ts"; import type { Mutation } from "./types.ts"; +import { jsx } from "react/jsx-runtime"; /** * Subscribe to a mutation's status, as well as accessing a local `run` method. @@ -31,15 +32,16 @@ export function useMutate< } export type UseMutateResult = - & UseMutateResultBase + & UseMutateResultBase & ( | UseMutateSuccess | UseMutateError | UseMutateIdle ); -export interface UseMutateResultBase { +export interface UseMutateResultBase { run: (...args: Args) => void; + runWithResult: (...args: Args) => Promise; clear: () => void; } @@ -93,7 +95,7 @@ export interface UseMutateIdle { isOptimisticData: boolean; } -type AnyMutationState = +type AnyMutationStateWithoutRun = & Omit< UseMutateIdle, "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" @@ -107,6 +109,10 @@ type AnyMutationState = isError: boolean; }; +export type AnyMutationState = + & AnyMutationStateWithoutRun + & UseMutateResultBase; + function initialState() { return { status: "idle", @@ -133,8 +139,8 @@ class Observer { } watched: Set = new Set(); - state: AnyMutationState = initialState(); - setState(newState: Partial>) { + state: AnyMutationStateWithoutRun = initialState(); + setState(newState: Partial>) { let updateUi = false; const current: Record = this.state; for (const [key, value] of Object.entries(newState)) { @@ -164,72 +170,82 @@ class Observer { }`; } - binding: UseMutateResult = ((self: this) => ({ - run(...args: Args) { - const mutation = self.mutation; - if (!mutation) return; - self.currentArgs = args; - const key = mutation.key(args); - if (key !== self.currentKey) { - self.currentKey = key; - self.unsubscribe?.(); - self.unsubscribe = mutation.subscribe( - mutation.key(args), - ({ status, error, result }) => { - if (status === "idle") { - self.setState({ - isMutating: false, - isPending: false, - isOptimisticData: false, - }); - return; - } - const hasError = error != null; - const hasResult = result != null; - - self.setState({ - status: hasError - ? "error" - : hasResult - ? "success" - : status === "mutating" - ? "mutating" - : "idle", - error: error ?? undefined, - errorMessage: self.computeErrorMessage(error ?? undefined), - result: result ?? undefined, - isMutating: status === "mutating", - isPending: status === "mutating" || status === "refetching", - isSuccess: hasResult && !hasError, - isError: hasError, - isOptimisticData: status === "waiting" || status === "mutating" || - status === "refetching", + run(...args: Args) { + const mutation = this.mutation; + if (!mutation) return; + this.currentArgs = args; + const key = mutation.key(args); + if (key !== this.currentKey) { + this.currentKey = key; + this.unsubscribe?.(); + this.unsubscribe = mutation.subscribe( + mutation.key(args), + ({ status, error, result }) => { + if (status === "idle") { + this.setState({ + isMutating: false, + isPending: false, + isOptimisticData: false, }); - }, - ); + return; + } + const hasError = error != null; + const hasResult = result != null; + + this.setState({ + status: hasError + ? "error" + : hasResult + ? "success" + : status === "mutating" + ? "mutating" + : "idle", + error: error ?? undefined, + errorMessage: this.computeErrorMessage(error ?? undefined), + result: result ?? undefined, + isMutating: status === "mutating", + isPending: status === "mutating" || status === "refetching", + isSuccess: hasResult && !hasError, + isError: hasError, + isOptimisticData: status === "waiting" || status === "mutating" || + status === "refetching", + }); + }, + ); + } + // Use global error/success handling if this usage of the hook doesn't check for + // errors or success. This makes it act pretty awesome in terms of defaults. + // You don't have to worry about result UI, they'll surface exactly once. + const watchesError = this.watched.has("isError") || + this.watched.has("error") || this.watched.has("errorMessage"); + const watchesSuccess = this.watched.has("isSuccess") || + this.watched.has("result"); + const promise = mutation.runAndReturn(...args) + .then((result) => { + if (!watchesSuccess && mutation.describeResult) { + const message = mutation.describeResult(args, result); + if (message && mutation.client.reportSuccess) { + mutation.client.reportSuccess(message); + } + } + }); + promise.catch((err) => { + if (!watchesError) { + const message = `Failed to ${mutation.describe(...args)}: ${ + errMessage(err) + }`; + mutation.client.reportError(message, err); } - // Use global error/success handling if this usage of the hook doesn't check for - // errors or success. This makes it act pretty awesome in terms of defaults. - // You don't have to worry about result UI, they'll surface exactly once. - const watchesError = self.watched.has("isError") || - self.watched.has("error") || self.watched.has("errorMessage"); - const watchesSuccess = self.watched.has("isSuccess") || - self.watched.has("result"); - mutation.runAndReturn(...args) - .then((result) => { - if (!watchesSuccess && mutation.describeResult) { - const message = mutation.describeResult(args, result); - if (message && mutation.client.reportSuccess) { - mutation.client.reportSuccess(message); - } - } - }) - .catch((err) => { - if (!watchesError) { - const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; - mutation.client.reportError(message, err); - } - }); + }); + return promise; + } + + binding: UseMutateResult = ((self: this) => ({ + run(...args) { + return self.run(...args); + }, + runWithResult(...args) { + return self.run(...args); }, clear() { self.setState({ @@ -287,7 +303,7 @@ interface BaseButtonProps { isPending: boolean; } -interface MutationButtonComponent { +export interface MutationButtonComponent { ( props: & MutationButtonProps @@ -299,11 +315,27 @@ interface MutationButtonComponent { export interface MutationButtonProps { mutation: | Mutation - | Pick, "run" | "status" | "isPending">; + | UseMutateResult; /** Preventing default will interrupt the mutation */ args: Args | ((e: MouseEvent) => Args | null); /** Preventing default will interrupt the mutation */ onClick?: (e: MouseEvent) => void; + + /** Omitting this will use the global error handler */ + onError?: (result: unknown) => void; + /** Omitting this will use the global success handler */ + onSuccess?: (result: Result) => void; + + /** Global event handlers will still be called! */ + onSettled?: ( + event: { + status: "success"; + result: Result; + } | { + status: "error"; + error: unknown; + }, + ) => void; } /** @@ -348,23 +380,44 @@ function GenericMutationButton< Component: ResolveMutationButtonFc, props: MutationButtonProps & Props, ) { - const { mutation, args, onClick, ...forwarded } = props; + const { + mutation, + args, + onClick, + onError, + onSuccess, + onSettled, + ...forwarded + } = props; forwarded satisfies Omit>; const localHook = useMutate("subscribe" in mutation ? mutation : null); const state = "subscribe" in mutation ? localHook : mutation; - return ( - { + if (onError) void state.isError; // subscribe to the events + if (onSuccess) void state.isSuccess; // subscribe to the events + + // NOTE: the JSR has trouble with JSX syntax for some reason. + return jsx( + Component, + { + ...forwarded, + onClick: useCallback((e: MouseEvent) => { onClick?.(e); if (e.defaultPrevented) return; const computedArgs = typeof args === "function" ? args(e) : args; if (!computedArgs || e.defaultPrevented) return; - state.run(...computedArgs); - }, [state])} - isPending={state.isPending} - /> + state.runWithResult(...computedArgs) + .then((result) => { + onSuccess?.(result); + onSettled?.({ status: "success", result }); + }) + .catch((error) => { + onError?.(error); + onSettled?.({ status: "error", error }); + }); + }, [state]), + isPending: state.isPending, + } satisfies Parameters[0], ); } diff --git a/test/queued.test.ts b/test/blocking.test.ts similarity index 83% rename from test/queued.test.ts rename to test/blocking.test.ts index cc0f9dfee237302e0a90d14d67a00e72b2ee2e2e..6ccf3be0acfa14be711dcf835cdea7b843b1d14c 100644 --- a/test/queued.test.ts +++ b/test/blocking.test.ts @@ -5,7 +5,8 @@ import { test } from "vitest"; // Helper to create a test mutation client function createTestClient() { - const errors: unknown[] = []; + const errors: Array<{ message: string; error: unknown }> = []; + const successes: string[] = []; const client = new MutationClient({ context: { userId: "test-user" }, getOptimisticHelpers({ onRestore }) { @@ -17,11 +18,14 @@ function createTestClient() { }; }, reportError(message, error) { - errors.push(error); + errors.push({ message, error }); + }, + reportSuccess(message) { + successes.push(message); }, }); - return { client, errors }; + return { client, errors, successes }; } const testStore = new Map(); @@ -40,7 +44,7 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -test("QueuedMutation - basic mutation success", async () => { +test("BlockingMutation - basic mutation success", async () => { const { client } = createTestClient(); let mutateCallCount = 0; let refetchCallCount = 0; @@ -52,6 +56,7 @@ test("QueuedMutation - basic mutation success", async () => { return `result-${value}`; }, describe: "test mutation", + describeResult: "Success", optimistic() { // Empty optimistic update }, @@ -70,7 +75,7 @@ test("QueuedMutation - basic mutation success", async () => { assertEquals(refetchCallCount, 1); }); -test("QueuedMutation - run() catches errors", async () => { +test("BlockingMutation - run() catches errors", async () => { const { client, errors } = createTestClient(); const mutation = client.defineBlocking({ @@ -78,6 +83,7 @@ test("QueuedMutation - run() catches errors", async () => { throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, }); @@ -86,10 +92,10 @@ test("QueuedMutation - run() catches errors", async () => { await delay(50); assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "mutation failed"); + assertEquals((errors[0].error as Error).message, "mutation failed"); }); -test("QueuedMutation - runAndReturn() rejects on error", async () => { +test("BlockingMutation - runAndReturn() rejects on error", async () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -97,6 +103,7 @@ test("QueuedMutation - runAndReturn() rejects on error", async () => { throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, }); @@ -108,7 +115,7 @@ test("QueuedMutation - runAndReturn() rejects on error", async () => { ); }); -test("QueuedMutation - optimistic updates are applied immediately", async () => { +test("BlockingMutation - optimistic updates are applied immediately", async () => { const { client } = createTestClient(); testStore.clear(); @@ -118,6 +125,7 @@ test("QueuedMutation - optimistic updates are applied immediately", async () => return value; }, describe: "set value", + describeResult: "Success", optimistic({ args, helpers }) { const [key, value] = args; helpers.setValue(key, value); @@ -135,7 +143,7 @@ test("QueuedMutation - optimistic updates are applied immediately", async () => await delay(10); }); -test("QueuedMutation - rollback on error", async () => { +test("BlockingMutation - rollback on error", async () => { const { client } = createTestClient(); testStore.clear(); @@ -145,6 +153,7 @@ test("QueuedMutation - rollback on error", async () => { throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic({ args, helpers }) { const [key, value] = args; helpers.setValue(key, value); @@ -158,7 +167,7 @@ test("QueuedMutation - rollback on error", async () => { assertEquals(testStore.has("key1"), false); }); -test("QueuedMutation - onSuccess callback is called", async () => { +test("BlockingMutation - onSuccess callback is called", async () => { const { client } = createTestClient(); const successResults: string[] = []; @@ -167,6 +176,7 @@ test("QueuedMutation - onSuccess callback is called", async () => { return `result-${value}`; }, describe: "test mutation", + describeResult: "Success", optimistic({ onSuccess }) { onSuccess((result) => { successResults.push(result); @@ -180,7 +190,7 @@ test("QueuedMutation - onSuccess callback is called", async () => { assertEquals(successResults, ["result-test"]); }); -test("QueuedMutation - mutations with same key execute serially", async () => { +test("BlockingMutation - mutations with same key execute serially", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; @@ -192,6 +202,7 @@ test("QueuedMutation - mutations with same key execute serially", async () => { return id; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, refetchOnSuccess: false, @@ -211,7 +222,7 @@ test("QueuedMutation - mutations with same key execute serially", async () => { assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); }); -test("QueuedMutation - mutations with different keys execute in parallel", async () => { +test("BlockingMutation - mutations with different keys execute in parallel", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; @@ -223,6 +234,7 @@ test("QueuedMutation - mutations with different keys execute in parallel", async return id; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, key({ args }) { @@ -241,7 +253,7 @@ test("QueuedMutation - mutations with different keys execute in parallel", async assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); }); -test("QueuedMutation - key() returns JSON stringified key", () => { +test("BlockingMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -249,6 +261,7 @@ test("QueuedMutation - key() returns JSON stringified key", () => { return id; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, key({ args }) { @@ -260,7 +273,7 @@ test("QueuedMutation - key() returns JSON stringified key", () => { assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); }); -test("QueuedMutation - key() defaults to 'shared' when no key function", () => { +test("BlockingMutation - key() defaults to 'shared' when no key function", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -268,6 +281,7 @@ test("QueuedMutation - key() defaults to 'shared' when no key function", () => { return id; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, }); @@ -275,7 +289,7 @@ test("QueuedMutation - key() defaults to 'shared' when no key function", () => { assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); }); -test("QueuedMutation - key() can return array", () => { +test("BlockingMutation - key() can return array", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -283,6 +297,7 @@ test("QueuedMutation - key() can return array", () => { return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, key({ args }) { @@ -297,7 +312,7 @@ test("QueuedMutation - key() can return array", () => { ); }); -test("QueuedMutation - describe() with string", () => { +test("BlockingMutation - describe() with string", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -305,6 +320,7 @@ test("QueuedMutation - describe() with string", () => { return value; }, describe: "create item", + describeResult: "Success", optimistic() {}, async refetch() {}, }); @@ -312,7 +328,7 @@ test("QueuedMutation - describe() with string", () => { assertEquals(mutation.describe("test"), "create item"); }); -test("QueuedMutation - describe() with function", () => { +test("BlockingMutation - describe() with function", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -330,7 +346,7 @@ test("QueuedMutation - describe() with function", () => { assertEquals(mutation.describe("123"), "delete item 123"); }); -test("QueuedMutation - describe() receives context", () => { +test("BlockingMutation - describe() receives context", () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -351,7 +367,7 @@ test("QueuedMutation - describe() receives context", () => { ); }); -test("QueuedMutation - subscribe() tracks mutation events", async () => { +test("BlockingMutation - subscribe() tracks mutation events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); @@ -361,6 +377,7 @@ test("QueuedMutation - subscribe() tracks mutation events", async () => { return `result-${value}`; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() { await delay(5); @@ -380,7 +397,7 @@ test("QueuedMutation - subscribe() tracks mutation events", async () => { assertEquals(tracker.events.some((e) => e.status === "refetching"), true); }); -test("QueuedMutation - unsubscribe stops receiving events", async () => { +test("BlockingMutation - unsubscribe stops receiving events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); @@ -390,6 +407,7 @@ test("QueuedMutation - unsubscribe stops receiving events", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, refetchOnSuccess: false, @@ -407,7 +425,7 @@ test("QueuedMutation - unsubscribe stops receiving events", async () => { assertEquals(tracker.events.length, 0); }); -test("QueuedMutation - refetchOnSuccess can be disabled", async () => { +test("BlockingMutation - refetchOnSuccess can be disabled", async () => { const { client } = createTestClient(); let refetchCallCount = 0; @@ -416,6 +434,7 @@ test("QueuedMutation - refetchOnSuccess can be disabled", async () => { return _value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() { refetchCallCount++; @@ -428,7 +447,7 @@ test("QueuedMutation - refetchOnSuccess can be disabled", async () => { assertEquals(refetchCallCount, 0); }); -test("QueuedMutation - refetch is called on error", async () => { +test("BlockingMutation - refetch is called on error", async () => { const { client } = createTestClient(); let refetchCallCount = 0; @@ -437,6 +456,7 @@ test("QueuedMutation - refetch is called on error", async () => { throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic() {}, async refetch() { refetchCallCount++; @@ -448,7 +468,7 @@ test("QueuedMutation - refetch is called on error", async () => { assertEquals(refetchCallCount, 1); }); -test("QueuedMutation - queued mutations are cancelled on error", async () => { +test("BlockingMutation - queued mutations are cancelled on error", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; @@ -463,6 +483,7 @@ test("QueuedMutation - queued mutations are cancelled on error", async () => { return id; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, key() { @@ -482,7 +503,7 @@ test("QueuedMutation - queued mutations are cancelled on error", async () => { assertEquals(executionOrder, ["start-1"]); }); -test("QueuedMutation - rollbacks are called in reverse order on error", async () => { +test("BlockingMutation - rollbacks are called in reverse order on error", async () => { const { client } = createTestClient(); const rollbackOrder: number[] = []; @@ -491,6 +512,7 @@ test("QueuedMutation - rollbacks are called in reverse order on error", async () throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic({ onRestore }) { onRestore(() => rollbackOrder.push(1)); onRestore(() => rollbackOrder.push(2)); @@ -505,7 +527,7 @@ test("QueuedMutation - rollbacks are called in reverse order on error", async () assertEquals(rollbackOrder, [3, 2, 1]); }); -test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation", async () => { +test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => { const { client } = createTestClient(); const rollbackOrder: string[] = []; @@ -518,6 +540,7 @@ test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation return id; }, describe: "test mutation", + describeResult: "Success", optimistic({ args: [id], onRestore }) { onRestore(() => rollbackOrder.push(`rollback-${id}`)); }, @@ -538,7 +561,7 @@ test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation assertEquals(rollbackOrder, ["rollback-fail"]); }); -test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => { +test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => { const { client } = createTestClient(); let capturedOnRestore: ((cb: () => void) => void) | null = null; @@ -547,6 +570,7 @@ test("QueuedMutation - onRestore throws error if called after optimistic phase", return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic({ onRestore }) { capturedOnRestore = onRestore; }, @@ -569,7 +593,7 @@ test("QueuedMutation - onRestore throws error if called after optimistic phase", ); }); -test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => { +test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => { const { client } = createTestClient(); let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; @@ -578,6 +602,7 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase", return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic({ onSuccess }) { capturedOnSuccess = onSuccess; }, @@ -600,7 +625,7 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase", ); }); -test("QueuedMutation - error during optimistic update is rejected immediately", async () => { +test("BlockingMutation - error during optimistic update is rejected immediately", async () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -608,6 +633,7 @@ test("QueuedMutation - error during optimistic update is rejected immediately", return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic() { throw new Error("optimistic update failed"); }, @@ -621,7 +647,7 @@ test("QueuedMutation - error during optimistic update is rejected immediately", ); }); -test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => { +test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => { const { client } = createTestClient(); const rollbackOrder: number[] = []; @@ -630,6 +656,7 @@ test("QueuedMutation - error during optimistic update rolls back registered call return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic({ onRestore }) { onRestore(() => rollbackOrder.push(1)); onRestore(() => rollbackOrder.push(2)); @@ -645,7 +672,7 @@ test("QueuedMutation - error during optimistic update rolls back registered call assertEquals(rollbackOrder, [1, 2]); }); -test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => { +test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => { const { client, errors } = createTestClient(); const mutation = client.defineBlocking({ @@ -653,6 +680,7 @@ test("QueuedMutation - refetch errors are reported but don't fail mutation", asy return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() { throw new Error("refetch failed"); @@ -666,10 +694,10 @@ test("QueuedMutation - refetch errors are reported but don't fail mutation", asy // But refetch error should be reported await delay(20); assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "refetch failed"); + assertEquals((errors[0].error as Error).message, "refetch failed"); }); -test("QueuedMutation - optimistic function receives args and helpers", async () => { +test("BlockingMutation - optimistic function receives args and helpers", async () => { const { client } = createTestClient(); let receivedArgs: unknown[] | undefined; let receivedHelpers: unknown | undefined; @@ -679,6 +707,7 @@ test("QueuedMutation - optimistic function receives args and helpers", async () return "result"; }, describe: "test mutation", + describeResult: "Success", optimistic({ args, helpers }) { receivedArgs = args; receivedHelpers = helpers; @@ -692,7 +721,7 @@ test("QueuedMutation - optimistic function receives args and helpers", async () assertEquals(typeof receivedHelpers, "object"); }); -test("QueuedMutation - refetch receives context and args", async () => { +test("BlockingMutation - refetch receives context and args", async () => { const { client } = createTestClient(); let receivedUserId: string | undefined; let receivedArgs: unknown[] | undefined; @@ -702,6 +731,7 @@ test("QueuedMutation - refetch receives context and args", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch({ userId, args }) { receivedUserId = userId; @@ -715,7 +745,7 @@ test("QueuedMutation - refetch receives context and args", async () => { assertEquals(receivedArgs, ["test-id", "test-value"]); }); -test("QueuedMutation - notifies error on mutation failure", async () => { +test("BlockingMutation - notifies error on mutation failure", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); @@ -725,6 +755,7 @@ test("QueuedMutation - notifies error on mutation failure", async () => { throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, }); @@ -742,7 +773,7 @@ test("QueuedMutation - notifies error on mutation failure", async () => { assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); }); -test("QueuedMutation - multiple subscribers receive events", async () => { +test("BlockingMutation - multiple subscribers receive events", async () => { const { client } = createTestClient(); const tracker1 = createEventTracker(); const tracker2 = createEventTracker(); @@ -753,6 +784,7 @@ test("QueuedMutation - multiple subscribers receive events", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, refetchOnSuccess: false, @@ -770,7 +802,7 @@ test("QueuedMutation - multiple subscribers receive events", async () => { assertEquals(tracker1.events.length > 0, true); }); -test("QueuedMutation - onSuccess is called before mutation resolves", async () => { +test("BlockingMutation - onSuccess is called before mutation resolves", async () => { const { client } = createTestClient(); const callOrder: string[] = []; @@ -779,6 +811,7 @@ test("QueuedMutation - onSuccess is called before mutation resolves", async () = return value; }, describe: "test mutation", + describeResult: "Success", optimistic({ onSuccess }) { onSuccess(() => { callOrder.push("onSuccess"); @@ -800,7 +833,7 @@ test("QueuedMutation - onSuccess is called before mutation resolves", async () = assertEquals(callOrder, ["onSuccess", "then"]); }); -test("QueuedMutation - result is passed to notification on success", async () => { +test("BlockingMutation - result is passed to notification on success", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); @@ -810,6 +843,7 @@ test("QueuedMutation - result is passed to notification on success", async () => return `result-${value}`; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() { await delay(5); @@ -830,7 +864,7 @@ test("QueuedMutation - result is passed to notification on success", async () => assertEquals(refetchingEvents[0]?.result, "result-test"); }); -test("QueuedMutation - channel is reused for same key", async () => { +test("BlockingMutation - channel is reused for same key", async () => { const { client } = createTestClient(); const events: string[] = []; @@ -840,6 +874,7 @@ test("QueuedMutation - channel is reused for same key", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, refetchOnSuccess: false, @@ -856,7 +891,7 @@ test("QueuedMutation - channel is reused for same key", async () => { assertEquals(events, ["mutate-first", "mutate-second"]); }); -test("QueuedMutation - empty queue after all mutations complete", async () => { +test("BlockingMutation - empty queue after all mutations complete", async () => { const { client } = createTestClient(); const mutation = client.defineBlocking({ @@ -865,6 +900,7 @@ test("QueuedMutation - empty queue after all mutations complete", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() {}, refetchOnSuccess: false, @@ -889,7 +925,7 @@ test("QueuedMutation - empty queue after all mutations complete", async () => { assertEquals(duration < 15, true); }); -test("QueuedMutation - multiple onSuccess callbacks are all called", async () => { +test("BlockingMutation - multiple onSuccess callbacks are all called", async () => { const { client } = createTestClient(); const results: string[] = []; @@ -898,6 +934,7 @@ test("QueuedMutation - multiple onSuccess callbacks are all called", async () => return value; }, describe: "test mutation", + describeResult: "Success", optimistic({ onSuccess }) { onSuccess((result) => results.push(`first-${result}`)); onSuccess((result) => results.push(`second-${result}`)); @@ -912,7 +949,7 @@ test("QueuedMutation - multiple onSuccess callbacks are all called", async () => assertEquals(results, ["first-test", "second-test", "third-test"]); }); -test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { +test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { const { client } = createTestClient(); let refetchCalled = false; @@ -921,6 +958,7 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { return value; }, describe: "test mutation", + describeResult: "Success", optimistic() {}, async refetch() { refetchCalled = true; @@ -935,7 +973,7 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { assertEquals(refetchCalled, false); }); -test("QueuedMutation - refetch error after mutation failure is reported", async () => { +test("BlockingMutation - refetch error after mutation failure is reported", async () => { const { client, errors } = createTestClient(); const mutation = client.defineBlocking({ @@ -943,6 +981,7 @@ test("QueuedMutation - refetch error after mutation failure is reported", async throw new Error("mutation failed"); }, describe: "failing mutation", + describeResult: "Success", optimistic() {}, async refetch() { throw new Error("refetch also failed"); @@ -961,7 +1000,7 @@ test("QueuedMutation - refetch error after mutation failure is reported", async // Should have both the mutation error and refetch error reported assertEquals(errors.length >= 1, true); assertEquals( - (errors[errors.length - 1] as Error).message, + (errors[errors.length - 1].error as Error).message, "refetch also failed", ); }); diff --git a/test/batch.test.ts b/test/debounced.test.ts similarity index 89% rename from test/batch.test.ts rename to test/debounced.test.ts index 50a6ca26a285d7d4fb1503b12531665295478fe5..68139a968041fc8eb74ec2c645fd01f838cfbfd9 100644 --- a/test/batch.test.ts +++ b/test/debounced.test.ts @@ -8,7 +8,8 @@ const testStore = new Map(); // Helper to create a test mutation client function createTestClient() { - const errors: unknown[] = []; + const errors: Array<{ message: string; error: unknown }> = []; + const successes: string[] = []; const client = new MutationClient({ context: { userId: "test-user" }, getOptimisticHelpers({ onRestore }) { @@ -32,11 +33,14 @@ function createTestClient() { }; }, reportError(message, error) { - errors.push(error); + errors.push({ message, error }); + }, + reportSuccess(message) { + successes.push(message); }, }); - return { client, errors }; + return { client, errors, successes }; } // Helper to track mutation events @@ -57,7 +61,7 @@ function delay(ms: number) { // Basic functionality tests // ============================================================================ -test("BatchMutation - basic mutation success with debounce", async () => { +test("DebouncedMutation - basic mutation success with debounce", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -79,6 +83,7 @@ test("BatchMutation - basic mutation success with debounce", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() { refetchCallCount++; }, @@ -93,7 +98,7 @@ test("BatchMutation - basic mutation success with debounce", async () => { assertEquals(testStore.get("counter"), 5); }); -test("BatchMutation - run() catches errors", async () => { +test("DebouncedMutation - run() catches errors", async () => { const { client, errors } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -110,6 +115,7 @@ test("BatchMutation - run() catches errors", async () => { throw new Error("commit failed"); }, describe: "failing mutation", + describeResult: "Success", async refetch() {}, }); @@ -117,10 +123,10 @@ test("BatchMutation - run() catches errors", async () => { await delay(100); assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "commit failed"); + assertEquals((errors[0].error as Error).message, "commit failed"); }); -test("BatchMutation - runAndReturn() rejects on error", async () => { +test("DebouncedMutation - runAndReturn() rejects on error", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -137,6 +143,7 @@ test("BatchMutation - runAndReturn() rejects on error", async () => { throw new Error("commit failed"); }, describe: "failing mutation", + describeResult: "Success", async refetch() {}, }); @@ -151,7 +158,7 @@ test("BatchMutation - runAndReturn() rejects on error", async () => { // Debounce mode tests // ============================================================================ -test("BatchMutation - debounce batches rapid calls", async () => { +test("DebouncedMutation - debounce batches rapid calls", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -173,6 +180,7 @@ test("BatchMutation - debounce batches rapid calls", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -194,7 +202,7 @@ test("BatchMutation - debounce batches rapid calls", async () => { assertEquals(commitArgs, [{ initial: 0, current: 6 }]); }); -test("BatchMutation - debounce resets timer on each call", async () => { +test("DebouncedMutation - debounce resets timer on each call", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -214,6 +222,7 @@ test("BatchMutation - debounce resets timer on each call", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -242,7 +251,7 @@ test("BatchMutation - debounce resets timer on each call", async () => { assertEquals(commitCallCount, 1); }); -test("BatchMutation - debounce separates batches after timeout", async () => { +test("DebouncedMutation - debounce separates batches after timeout", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -264,6 +273,7 @@ test("BatchMutation - debounce separates batches after timeout", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -287,7 +297,7 @@ test("BatchMutation - debounce separates batches after timeout", async () => { // Throttle mode tests // ============================================================================ -test("BatchMutation - throttle commits immediately on first call", async () => { +test("DebouncedMutation - throttle commits immediately on first call", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -308,6 +318,7 @@ test("BatchMutation - throttle commits immediately on first call", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -317,7 +328,7 @@ test("BatchMutation - throttle commits immediately on first call", async () => { assertEquals(commitTime < 20, true); }); -test("BatchMutation - throttle batches calls within time window", async () => { +test("DebouncedMutation - throttle batches calls within time window", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -340,6 +351,7 @@ test("BatchMutation - throttle batches calls within time window", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -370,7 +382,7 @@ test("BatchMutation - throttle batches calls within time window", async () => { assertEquals(commitArgs[1], { initial: 1, current: 6 }); }); -test("BatchMutation - throttle allows new batch after time window", async () => { +test("DebouncedMutation - throttle allows new batch after time window", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -390,6 +402,7 @@ test("BatchMutation - throttle allows new batch after time window", async () => return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -413,7 +426,7 @@ test("BatchMutation - throttle allows new batch after time window", async () => // No-op detection tests // ============================================================================ -test("BatchMutation - skips commit when value unchanged", async () => { +test("DebouncedMutation - skips commit when value unchanged", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 5); @@ -433,6 +446,7 @@ test("BatchMutation - skips commit when value unchanged", async () => { return current - initial; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -453,7 +467,7 @@ test("BatchMutation - skips commit when value unchanged", async () => { assertEquals(testStore.get("counter"), 5); }); -test("BatchMutation - uses deepEquals for comparison", async () => { +test("DebouncedMutation - uses deepEquals for comparison", async () => { const errors: unknown[] = []; const objectStore: { value: { count: number } | null } = { value: { count: 0 }, @@ -492,6 +506,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { return null; }, describe: "set count", + describeResult: "Success", async refetch() {}, }); @@ -503,7 +518,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { assertEquals(commitCallCount, 0); }); -test("BatchMutation - custom deepEquals function", async () => { +test("DebouncedMutation - custom deepEquals function", async () => { const errors: unknown[] = []; let compareCallCount = 0; @@ -543,6 +558,7 @@ test("BatchMutation - custom deepEquals function", async () => { throw new Error("commit failed"); }, describe: "failing mutation", + describeResult: "Success", async refetch() {}, }); @@ -559,7 +575,7 @@ test("BatchMutation - custom deepEquals function", async () => { // Rollback tests // ============================================================================ -test("BatchMutation - rollback on commit error", async () => { +test("DebouncedMutation - rollback on commit error", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 10); @@ -576,6 +592,7 @@ test("BatchMutation - rollback on commit error", async () => { throw new Error("commit failed"); }, describe: "failing mutation", + describeResult: "Success", async refetch() {}, }); @@ -589,7 +606,7 @@ test("BatchMutation - rollback on commit error", async () => { assertEquals(testStore.get("counter"), 10); }); -test("BatchMutation - error event includes error details", async () => { +test("DebouncedMutation - error event includes error details", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -608,6 +625,7 @@ test("BatchMutation - error event includes error details", async () => { throw new Error("commit failed"); }, describe: "failing mutation", + describeResult: "Success", async refetch() {}, }); @@ -627,7 +645,7 @@ test("BatchMutation - error event includes error details", async () => { // Key handling tests // ============================================================================ -test("BatchMutation - key() returns JSON stringified key", () => { +test("DebouncedMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); testStore.clear(); @@ -641,13 +659,14 @@ test("BatchMutation - key() returns JSON stringified key", () => { return null; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); }); -test("BatchMutation - key() can return array", () => { +test("DebouncedMutation - key() can return array", () => { const { client } = createTestClient(); testStore.clear(); @@ -661,6 +680,7 @@ test("BatchMutation - key() can return array", () => { return null; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -670,7 +690,7 @@ test("BatchMutation - key() can return array", () => { ); }); -test("BatchMutation - different keys create separate batches", async () => { +test("DebouncedMutation - different keys create separate batches", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter-a", 0); @@ -685,12 +705,13 @@ test("BatchMutation - different keys create separate batches", async () => { mode: "debounce", time: 20, key: ({ args }) => args[0], - getValue: (_, key) => testStore.get(`counter-${key}`) ?? 0, + getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0, async commit({ current }) { commitCallCount++; return current; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -711,7 +732,7 @@ test("BatchMutation - different keys create separate batches", async () => { // Describe tests // ============================================================================ -test("BatchMutation - describe() with string", () => { +test("DebouncedMutation - describe() with string", () => { const { client } = createTestClient(); testStore.clear(); @@ -725,13 +746,14 @@ test("BatchMutation - describe() with string", () => { return null; }, describe: "update counter", + describeResult: "Success", async refetch() {}, }); assertEquals(mutation.describe(5), "update counter"); }); -test("BatchMutation - describe() with function", () => { +test("DebouncedMutation - describe() with function", () => { const { client } = createTestClient(); testStore.clear(); @@ -745,6 +767,7 @@ test("BatchMutation - describe() with function", () => { return null; }, describe: ({ args }) => `increment by ${args[0]}`, + describeResult: "Success", async refetch() {}, }); @@ -755,7 +778,7 @@ test("BatchMutation - describe() with function", () => { // Promise resolution tests // ============================================================================ -test("BatchMutation - all pending promises resolve with same result", async () => { +test("DebouncedMutation - all pending promises resolve with same result", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -772,6 +795,7 @@ test("BatchMutation - all pending promises resolve with same result", async () = return { delta: current - initial, timestamp: Date.now() }; }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -791,7 +815,7 @@ test("BatchMutation - all pending promises resolve with same result", async () = assertEquals(result1.delta, 6); }); -test("BatchMutation - all pending promises reject with same error", async () => { +test("DebouncedMutation - all pending promises reject with same error", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -808,6 +832,7 @@ test("BatchMutation - all pending promises reject with same error", async () => throw new Error("batch commit failed"); }, describe: "increment counter", + describeResult: "Success", async refetch() {}, }); @@ -833,7 +858,7 @@ test("BatchMutation - all pending promises reject with same error", async () => // Edge case tests // ============================================================================ -test("BatchMutation - handles empty getValue result", async () => { +test("DebouncedMutation - handles empty getValue result", async () => { const { client } = createTestClient(); testStore.clear(); @@ -852,6 +877,7 @@ test("BatchMutation - handles empty getValue result", async () => { return { initial, current }; }, describe: "test mutation", + describeResult: "Success", }); const result = await mutation.runAndReturn(5); @@ -862,7 +888,7 @@ test("BatchMutation - handles empty getValue result", async () => { assertEquals(result.current, 5); }); -test("BatchMutation - channel cleanup after idle with no listeners", async () => { +test("DebouncedMutation - channel cleanup after idle with no listeners", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -879,6 +905,7 @@ test("BatchMutation - channel cleanup after idle with no listeners", async () => return current - initial; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -894,7 +921,7 @@ test("BatchMutation - channel cleanup after idle with no listeners", async () => assertEquals(testStore.get("counter"), 8); }); -test("BatchMutation - default time is 200ms", async () => { +test("DebouncedMutation - default time is 200ms", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -915,6 +942,7 @@ test("BatchMutation - default time is 200ms", async () => { return current - initial; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -926,7 +954,7 @@ test("BatchMutation - default time is 200ms", async () => { assertEquals(commitTime! <= 250, true); }); -test("BatchMutation - context is passed to getValue", async () => { +test("DebouncedMutation - context is passed to getValue", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -940,7 +968,7 @@ test("BatchMutation - context is passed to getValue", async () => { mode: "debounce", time: 20, key: () => "test-key", - getValue: ({ userId }, _) => { + getValue: ({ userId }) => { receivedUserId = userId; return testStore.get("counter") ?? 0; }, @@ -948,6 +976,7 @@ test("BatchMutation - context is passed to getValue", async () => { return current - initial; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -957,7 +986,7 @@ test("BatchMutation - context is passed to getValue", async () => { assertEquals(receivedUserId, "test-user"); }); -test("BatchMutation - context is passed to commit", async () => { +test("DebouncedMutation - context is passed to commit", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -977,6 +1006,7 @@ test("BatchMutation - context is passed to commit", async () => { return current - initial; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); @@ -986,7 +1016,7 @@ test("BatchMutation - context is passed to commit", async () => { assertEquals(receivedUserId, "test-user"); }); -test("BatchMutation - first args are used for commit", async () => { +test("DebouncedMutation - first args are used for commit", async () => { const { client } = createTestClient(); testStore.clear(); testStore.set("counter", 0); @@ -1006,6 +1036,7 @@ test("BatchMutation - first args are used for commit", async () => { return current - initial; }, describe: "test mutation", + describeResult: "Success", async refetch() {}, }); diff --git a/test/object-path-types.test.ts b/test/object-path.types.ts similarity index 100% rename from test/object-path-types.test.ts rename to test/object-path.types.ts -- 2.54.0