diff --git a/example/src/App.tsx b/example/src/App.tsx index cf05c93031fb5e99848aa732c90c14134355eee4..67e13035b581a3437a2c1990f5ee109fd7927e24 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,7 +3,7 @@ import { createMutationButton, MutationClient, queryClientOptimisticHelpers, - useMutation, + useMutate, } from "@clo/react-mutation"; import { queryOptions as queryOptions } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query"; @@ -49,7 +49,7 @@ const queryCounter = queryOptions({ // await client.invalidateQueries(queryCounter); // }, // }); -const mutIncrement = mutationClient.defineBatched({ +const mutIncrement = mutationClient.defineDebounced({ mode: "debounce", time: 200, @@ -92,7 +92,7 @@ const MutationButton = createMutationButton(CustomButton); function Counter() { const { data: { count } } = useSuspenseQuery(queryCounter); - const mutation = useMutation(mutIncrement); + const mutation = useMutate(mutIncrement); return (
diff --git a/readme.md b/readme.md index a2e4a3fd5cf6057c4332cfb493a34c64085bdf30..f86612a2c934f2ae0b5f7ffa0a95361d64e7bf6a 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ patterns and verbose code that is hard to review. The primary gains React Mutation provides are -- **Automatic error handling**. If a `useMutation` hook does not observe +- **Automatic error handling**. If a `useMutate` hook does not observe `isError`, unhandled errors will be propagated to a global handler, which can display a UI toast. Otherwise, the component can display the error locally. - Optimistic helpers allow defining rollbacks and refetching logic independant @@ -22,13 +22,13 @@ The primary gains React Mutation provides are This library declares two kinds of mutations. Each kind has different behavior around concurrent operations. -- [**Queued Mutations**](#Queued-Mutations): A mutation blocks the UI until it +- [**Blocking Mutations**](#Blocking-Mutations): 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, creations and deletions, and is similar to React Query's mutation system. -- [**Batched Mutations**](#Batched-Mutations): 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. +- [**Debounced Mutations**](#Debounced-Mutations): 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 these. This works great for auto-saving input fields, follow buttons, and is preferred whenever possible. @@ -37,7 +37,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an ```ts const queryClient = new QueryClient(); export const mutations = new MutationClient({ - // All properties in `context` are available within mutation functions. + // All properties in `context` are available within every function. context: { client: queryClient, // Can add any easy helpers for your codebase. @@ -45,29 +45,40 @@ export const mutations = new MutationClient({ get: (k: QueryKey) => client.getQueryData(k), }, - // Optimistic helpers are a second type of context, only available - // within optimistic update functions. The built in React Query helpers - // add many query cache mutating operations that automatically + // Optimistic helpers are a second type of context, only available within + // optimistic update functions. These functions are bound to each mutation, + // which means they can handle automatic rollbacks and query invalidation. getOptimisticHelpers: queryClientOptimisticHelpers(queryClient), // When call sites do not opt into handling errors, or a pending // mutation hook is unmounted, errors are sent to this function. // An example is to bind this to global a UI toast. - reportError(description: string, error: unknown) { - console.error("Mutation error:", error); + reportError(userFriendlyErrorMessage: string, error: unknown) { + showToastUI("error", userFriendlyErrorMessage); + console.error(error); // or send to telemetry + }, + + // Similarly, when call sites do opt into handling success. + reportSuccess(userFriendlySuccessMessage: string) { + showToastUI("success", userFriendlyErrorMessage); }, }) ``` -### Queued Mutations +### Blocking Mutations -A queued mutation is defined with `mutations.defineQueued`. +A blocking mutation is defined with `mutations.defineBlocking`. Example use cases: + +- A form to create a new resource. +- Button operations such as deleting or resyncing. +- Any case where it is unclear what the optimistic state should be. ```tsx const queryItemList = queryOptions({ ... }); const queryItem = (id: string) => queryOptions({ ... }); +// The convention is to name handlers starting with `mut` const mutDeleteItem = mutations.defineQueued({ // `mutate` comes first, is only worried about syncing with the backend. async mutate(id: string) { @@ -76,23 +87,31 @@ const mutDeleteItem = mutations.defineQueued({ }, optimistic({ client, get, helpers, args: [id] }) { + // Remove the matching items, but restore and refetch them on failure. helpers.arrayRemove(queryItemList, (item) => item === id); + // Remove this query from the client, but restore as stale and refetch it on failure. helpers.removeQuery(queryItem); }, + // Example: `Could not {description}` describe({ get, args: [id] }) { const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; - return `delete '${title}'`; + return `Delete '${get(queryItem(id))?.title ?? 'unknown'}'`; }, + // Example: `Successfully {description}` + describeResult: ({ get, args: [id] }) => + `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, - // since the optimistic handler is perfect, there is no need + // Since the optimistic handler is perfect, there is no need // to refetch any data once a success case is hit. refetchOnSuccess: false, }); +// React example. Since `error` and `result` are not destructed, messages are +// indicated through UI toasts from the mutation client. export function Example({ id }: { id: string }) { const { data: list } = useSuspenseQuery(queryItemList); - const { run } = useMutation(mutDeleteItem); + const { run } = useMutate(mutDeleteItem); return list.map((id) =>
  • @@ -101,27 +120,25 @@ export function Example({ id }: { id: string }) { } ``` -### Batched Mutations +### Debounced Mutations -A batched mutation is defined with `mutations.defineBatched`. +A debounced mutation is defined with `mutations.defineBatched`. ```tsx -const mutSetItemName = mutationClient.defineBatched({ - mode: "debounce", - time: 200, - - // start by mutating the optimistic state +const mutSetItemName = mutationClient.defineDebounced({ + // Think of your mutator in terms of how it applies optimistic state. optimistic({ helpers }, id: string, name: string) { helpers.objSet(queryItem(id), ["title"], name); }, - // a value is snapshot before calling `optimistic` and after the - // timer. if the snapshots differ, the `commit` function is called. + // A value is snapshotted *before* calling `optimistic`, and then again after + // the timer. If the snapshots differ, then `commit` function is called. getValue: ({ get }) => get(queryCounter)?.title ?? "", - // batch the same `id`s together + // Split different `id`s into their own debounces. key: ({ args: [id] }) => id, - // commit the result to the backend + // Commit the result to the backend. Here, you can observe the two snapshotted + // values and form an API request. async commit({ initial, current, args: [id] }) { const response = await fetch(`/items/${id}`, { method: "patch", @@ -130,6 +147,35 @@ const mutSetItemName = mutationClient.defineBatched({ if (!response.ok) throw new Error(`HTTP ${response.status}`); }, - describe: ({ get }) => `rename '${get(queryItem())?.title ?? 'unknown'}'`, + describe: ({ get, args: [id] }) => + `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`, + describeResult: ({ get, args: [id] }) => + `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, }); + +// React example. Since the error and result are read in this hook, +// the success and failure states will be driven through the component UI. +function Item({ id }: { id: string }) { + const { data: item } = useSuspenseQuery(queryItem(id)); + const { run, isSuccess, errorMessage } = useMutate(mutDeleteItem); + + // TODO: test this pattern. maybe introduce another hook for doing good input + // fields that hook could also support an "Undo" button. + return <> + { + run(e.target.value); + }} + /> + { + isSuccess + ? "Saved" + : errorMessage + ? "Error: " + errorMessage : null + } + +} ``` + +### diff --git a/src/batch.ts b/src/batch.ts index 5f3feaced24afdde2e8dfd028a6d1af433db807a..50c47a8cb6f2ff4892eef134ab193ca87161676b 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -23,7 +23,10 @@ export interface BatchMutationOptions< */ getValue: (context: Config["context"], ...args: Args) => Optimistic; - mode: "debounce" | "throttle"; + /** + * @default "debounce" + */ + mode?: "debounce" | "throttle"; /** * Milliseconds * @default 200 @@ -50,6 +53,17 @@ export interface BatchMutationOptions< | (( context: BatchCommitContext, 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?: + | string + | (( + context: BatchCommitContext, Optimistic, Config> & { result: Result }, + ) => string) + | null; /** * Refetch all of the data this mutation could have affected. */ @@ -97,6 +111,7 @@ interface BatchChannel { args: Args; resolve: (result: Result) => void; reject: (error: unknown) => void; + reportSuccessGlobally?: boolean; }>; } @@ -197,15 +212,33 @@ export class BatchMutation< return describe; } - /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ + describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { + const { describeResult } = this.#options; + if (describeResult === null || describeResult === undefined) 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 { - this.runAndReturn(...args).catch((error) => { + this.#runAndReturn(args, true).catch((error) => { this.#client.reportError(error); }); } /** Calls the mutation, treating the errors as promise rejection. */ runAndReturn(...args: Args): Promise { + return this.#runAndReturn(args, false); + } + + #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise { const key = this.key(args); const channel = this.#getOrPutChannel(key); @@ -260,7 +293,7 @@ export class BatchMutation< // Create promise for this caller const { promise, resolve, reject } = Promise.withResolvers(); - channel.pending.push({ args, resolve, reject }); + channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); // Set status to waiting and notify if (channel.status === "idle") { @@ -280,7 +313,7 @@ export class BatchMutation< ) { const time = this.#options.time ?? 200; - if (this.#options.mode === "debounce") { + if (this.#options.mode !== "throttle") { // Debounce: reset timer on each call if (channel.timer !== null) { clearTimeout(channel.timer); @@ -364,6 +397,15 @@ export class BatchMutation< // 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(); diff --git a/src/client.ts b/src/client.ts index 8eb510f81e9f63703db64bf0e02d6909691eb174..079c68afc182472494331f5910c2b2a5020e1b11 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,7 @@ export interface MutationClientOptions< events: OptimisticEvents, ) => OptimisticHelpers; reportError: (error: unknown) => void; + reportSuccess?: (message: string) => void; /** * Compare two values for deep equality. Used by BatchMutation to determine * if the optimistic state has changed from the initial snapshot. @@ -42,12 +43,14 @@ export class MutationClient< context: Context; getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; reportError: (error: unknown) => void; + reportSuccess?: (message: string) => void; deepEquals: (a: unknown, b: unknown) => boolean; constructor(options: MutationClientOptions) { this.context = options.context; this.getOptimisticHelpers = options.getOptimisticHelpers; this.reportError = options.reportError; + this.reportSuccess = options.reportSuccess; this.deepEquals = options.deepEquals ?? defaultDeepEquals; } @@ -56,7 +59,7 @@ export class MutationClient< * 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. */ - defineQueued( + defineBlocking( options: MutationOptions< Args, Result, @@ -77,7 +80,7 @@ export class MutationClient< * works great for auto-saving input fields, follow buttons, and is preferred * whenever possible. */ - defineBatched( + defineDebounced( options: BatchMutationOptions< Args, Result, diff --git a/src/mod.ts b/src/mod.ts index 21ac9148d39de84c0e44e813edf290e4f774d9b0..37cd2172f93dfefafdc97234dc9726ddd3f87201 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -14,10 +14,10 @@ export type { Mutation, MutationEvent } from "./types.ts"; export { createMutationButton, type MutationButtonProps, - useMutation, - type UseMutationError, - type UseMutationIdle, - type UseMutationResult, - type UseMutationResultBase, - type UseMutationSuccess, + useMutate, + type UseMutateError, + type UseMutateIdle, + type UseMutateResult, + type UseMutateResultBase, + type UseMutateSuccess, } from "./react.tsx"; diff --git a/src/queued.ts b/src/queued.ts index e705917632d5895de021b87a42c526d0c030e9da..5a60eb64314be27f95f55734602e46a51b2e42ee 100644 --- a/src/queued.ts +++ b/src/queued.ts @@ -19,13 +19,22 @@ export interface MutationOptions< * 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: (context: Config["context"], ...args: Args) => Promise; + 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" + * Set to null to suppress success reporting. + */ + 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. @@ -33,6 +42,7 @@ export interface MutationOptions< optimistic: (context: OptimisticContext) => void; /** * Refetch all of the data this mutation could have affected. + * Normally, optimistic helpers will perform * This is called automatically on errors. */ refetch?: (context: Config["context"] & { args: Args }) => Promise; @@ -154,9 +164,22 @@ export class QueuedMutation< : describe; } + describeResult(args: Args, result: Result): string | undefined { + const { describeResult } = this.#options; + if (describeResult === null || describeResult === undefined) 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.runAndReturn(...args).catch((error) => { + this.runAndReturn(...args).then((result) => { + const message = this.describeResult(args, result); + if (message && this.#client.reportSuccess) { + this.#client.reportSuccess(message); + } + }).catch((error) => { this.#client.reportError(error); }); } @@ -244,7 +267,7 @@ export class QueuedMutation< channel.status = "mutating"; this.#notify(channel, "mutating"); - this.#options.mutate(this.#client.context, ...args).then((result) => { + 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)); diff --git a/src/react.tsx b/src/react.tsx index cdfb9b62f0c8bc74cd23a3a74653f16b622216e1..d8379fb345ffa69336d67dfe0128f8afcb07b478 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -13,12 +13,12 @@ import type { Mutation } from "./types.ts"; * Subscribe to a mutation's status, as well as accessing a local `run` method. * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. */ -export function useMutation< +export function useMutate< Args extends unknown[], Result, >( mutation: Mutation | null, -): UseMutationResult { +): UseMutateResult { const [_, setRerender] = useState(0); const [observer] = useState(() => new Observer(setRerender)); useEffect(() => () => void observer.reset(), []); @@ -29,20 +29,20 @@ export function useMutation< return observer.binding; } -export type UseMutationResult = - & UseMutationResultBase +export type UseMutateResult = + & UseMutateResultBase & ( - | UseMutationSuccess - | UseMutationError - | UseMutationIdle + | UseMutateSuccess + | UseMutateError + | UseMutateIdle ); -export interface UseMutationResultBase { +export interface UseMutateResultBase { run: (...args: Args) => void; clear: () => void; } -export interface UseMutationSuccess { +export interface UseMutateSuccess { status: "success"; result: Result; error: undefined; @@ -57,7 +57,7 @@ export interface UseMutationSuccess { /** `true` when there is optimistic state applied. */ isOptimisticData: boolean; } -export interface UseMutationError { +export interface UseMutateError { status: "error"; result: undefined; error: unknown; @@ -72,7 +72,7 @@ export interface UseMutationError { /** `true` when there is optimistic state applied. */ isOptimisticData: boolean; } -export interface UseMutationIdle { +export interface UseMutateIdle { status: "idle" | "mutating"; result: undefined; error: undefined; @@ -90,7 +90,7 @@ export interface UseMutationIdle { type AnyMutationState = & Omit< - UseMutationIdle, + UseMutateIdle, "status" | "result" | "error" | "isSuccess" | "isError" > & { @@ -147,7 +147,7 @@ class Observer { this.state = initialState(); } - binding: UseMutationResult = ((self: this) => ({ + binding: UseMutateResult = ((self: this) => ({ run(...args: Args) { const mutation = self.mutation; if (!mutation) return; @@ -189,10 +189,13 @@ class Observer { }, ); } - // use global error handling if this usage of the hook doesnt check for - // errors this makes it act pretty awesome in terms of defaults. you don't - // have to worry about the errors, they'll surface exactly once. - if (self.watched.has("isError") || self.watched.has("error")) { + // 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 the errors/successes, they'll surface exactly once. + if ( + self.watched.has("isError") || self.watched.has("error") || + self.watched.has("isSuccess") || self.watched.has("result") + ) { mutation.runAndReturn(...args).catch(() => { // caught in event listener }); @@ -243,7 +246,7 @@ class Observer { self.watched.add("isOptimisticData"); return self.state.isOptimisticData; }, - } as UseMutationResult))(this); + } as UseMutateResult))(this); } interface BaseButtonProps { @@ -263,7 +266,7 @@ interface MutationButtonComponent { export interface MutationButtonProps { mutation: | Mutation - | Pick, "run" | "status" | "isPending">; + | Pick, "run" | "status" | "isPending">; /** Preventing default will interrupt the mutation */ args: Args | ((e: MouseEvent) => Args | null); /** Preventing default will interrupt the mutation */ @@ -274,7 +277,7 @@ export interface MutationButtonProps { * Wraps a custom button component with logic to execute a mutation. The wrapped * component must accept `onClick` and an `isPending` property. When the inner * component emits `onClick`, that will begin the mutation. This is a trival - * abstraction on top of `useMutation`, but with type gymnastics to allow safe + * abstraction on top of `useMutate`, but with type gymnastics to allow safe * types. */ export function createMutationButton( @@ -315,7 +318,7 @@ function GenericMutationButton< const { mutation, args, onClick, ...forwarded } = props; forwarded satisfies Omit>; - const localHook = useMutation("subscribe" in mutation ? mutation : null); + const localHook = useMutate("subscribe" in mutation ? mutation : null); const state = "subscribe" in mutation ? localHook : mutation; return ( diff --git a/test/batch.test.ts b/test/batch.test.ts index 31bfd3543a501cede9357717d99442e2e72a8db2..074b6160166044210803cfbbbde1ea1d21dc761b 100644 --- a/test/batch.test.ts +++ b/test/batch.test.ts @@ -65,7 +65,7 @@ test("BatchMutation - basic mutation success with debounce", async () => { let commitCallCount = 0; let refetchCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -98,7 +98,7 @@ test("BatchMutation - run() catches errors", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -125,7 +125,7 @@ test("BatchMutation - runAndReturn() rejects on error", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -159,7 +159,7 @@ test("BatchMutation - debounce batches rapid calls", async () => { let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -201,7 +201,7 @@ test("BatchMutation - debounce resets timer on each call", async () => { let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -250,7 +250,7 @@ test("BatchMutation - debounce separates batches after timeout", async () => { let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -295,7 +295,7 @@ test("BatchMutation - throttle commits immediately on first call", async () => { let commitTime = 0; const startTime = Date.now(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -325,7 +325,7 @@ test("BatchMutation - throttle batches calls within time window", async () => { let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -377,7 +377,7 @@ test("BatchMutation - throttle allows new batch after time window", async () => let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -420,7 +420,7 @@ test("BatchMutation - skips commit when value unchanged", async () => { let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -479,7 +479,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, count: number) { helpers.setCount(count); }, @@ -531,7 +531,7 @@ test("BatchMutation - custom deepEquals function", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -546,7 +546,9 @@ test("BatchMutation - custom deepEquals function", async () => { async refetch() {}, }); - await mutation.runAndReturn(5); + await mutation.runAndReturn(5).catch(() => { + // Expected to fail due to commit error + }); await delay(30); // Custom deepEquals should have been called @@ -562,7 +564,7 @@ test("BatchMutation - rollback on commit error", async () => { testStore.clear(); testStore.set("counter", 10); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -594,7 +596,7 @@ test("BatchMutation - error event includes error details", async () => { const tracker = createEventTracker(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -629,7 +631,7 @@ test("BatchMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic(_ctx, _id: string) {}, mode: "debounce", time: 20, @@ -649,7 +651,7 @@ test("BatchMutation - key() can return array", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic(_ctx, _id: string) {}, mode: "debounce", time: 20, @@ -676,7 +678,7 @@ test("BatchMutation - different keys create separate batches", async () => { let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, key: string, amount: number) { helpers.increment(`counter-${key}`, amount); }, @@ -713,7 +715,7 @@ test("BatchMutation - describe() with string", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic(_ctx, _amount: number) {}, mode: "debounce", time: 20, @@ -733,7 +735,7 @@ test("BatchMutation - describe() with function", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic(_ctx, _amount: number) {}, mode: "debounce", time: 20, @@ -758,7 +760,7 @@ test("BatchMutation - all pending promises resolve with same result", async () = testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -794,7 +796,7 @@ test("BatchMutation - all pending promises reject with same error", async () => testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -837,7 +839,7 @@ test("BatchMutation - handles empty getValue result", async () => { let commitCallCount = 0; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.setValue("nonexistent", amount); }, @@ -865,7 +867,7 @@ test("BatchMutation - channel cleanup after idle with no listeners", async () => testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -900,7 +902,7 @@ test("BatchMutation - default time is 200ms", async () => { let commitTime: number | null = null; const startTime = Date.now(); - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -931,7 +933,7 @@ test("BatchMutation - context is passed to getValue", async () => { let receivedUserId: string | undefined; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -962,7 +964,7 @@ test("BatchMutation - context is passed to commit", async () => { let receivedUserId: string | undefined; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -991,7 +993,7 @@ test("BatchMutation - first args are used for commit", async () => { let receivedArgs: [string, number] | undefined; - const mutation = client.defineBatched({ + const mutation = client.defineDebounced({ optimistic({ helpers }, _label: string, amount: number) { helpers.increment("counter", amount); }, diff --git a/test/queued.test.ts b/test/queued.test.ts index 3b4aaf9be8802de6efb5d1cb9bc222df6c13d980..85f287f1bc6da3ab9415fbae3701b10946dd808d 100644 --- a/test/queued.test.ts +++ b/test/queued.test.ts @@ -45,8 +45,8 @@ test("QueuedMutation - basic mutation success", async () => { let mutateCallCount = 0; let refetchCallCount = 0; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { mutateCallCount++; await delay(10); return `result-${value}`; @@ -73,8 +73,8 @@ test("QueuedMutation - basic mutation success", async () => { test("QueuedMutation - run() catches errors", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { throw new Error("mutation failed"); }, describe: "failing mutation", @@ -92,8 +92,8 @@ test("QueuedMutation - run() catches errors", async () => { test("QueuedMutation - runAndReturn() rejects on error", async () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { throw new Error("mutation failed"); }, describe: "failing mutation", @@ -112,8 +112,8 @@ test("QueuedMutation - optimistic updates are applied immediately", async () => const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineQueued({ - async mutate(_, _key: string, value: string) { + const mutation = client.defineBlocking({ + async mutate(_key: string, value: string) { await delay(50); return value; }, @@ -139,8 +139,8 @@ test("QueuedMutation - rollback on error", async () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineQueued({ - async mutate(_, _key: string, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_key: string, _value: string) { await delay(10); throw new Error("mutation failed"); }, @@ -162,8 +162,8 @@ test("QueuedMutation - onSuccess callback is called", async () => { const { client } = createTestClient(); const successResults: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return `result-${value}`; }, describe: "test mutation", @@ -184,8 +184,8 @@ test("QueuedMutation - mutations with same key execute serially", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(20); executionOrder.push(`end-${id}`); @@ -215,8 +215,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(20); executionOrder.push(`end-${id}`); @@ -244,8 +244,8 @@ test("QueuedMutation - mutations with different keys execute in parallel", async test("QueuedMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { return id; }, describe: "test mutation", @@ -263,8 +263,8 @@ test("QueuedMutation - key() returns JSON stringified key", () => { test("QueuedMutation - key() defaults to 'shared' when no key function", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { return id; }, describe: "test mutation", @@ -278,8 +278,8 @@ test("QueuedMutation - key() defaults to 'shared' when no key function", () => { test("QueuedMutation - key() can return array", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, _userId: string, _itemId: string) { + const mutation = client.defineBlocking({ + async mutate(_userId: string, _itemId: string) { return "result"; }, describe: "test mutation", @@ -300,8 +300,8 @@ test("QueuedMutation - key() can return array", () => { test("QueuedMutation - describe() with string", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return value; }, describe: "create item", @@ -315,8 +315,8 @@ test("QueuedMutation - describe() with string", () => { test("QueuedMutation - describe() with function", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { return id; }, describe({ args }) { @@ -333,8 +333,8 @@ test("QueuedMutation - describe() with function", () => { test("QueuedMutation - describe() receives context", () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { return id; }, describe({ userId, args }) { @@ -355,8 +355,8 @@ test("QueuedMutation - subscribe() tracks mutation events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { await delay(10); return `result-${value}`; }, @@ -384,8 +384,8 @@ test("QueuedMutation - unsubscribe stops receiving events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { await delay(10); return value; }, @@ -411,8 +411,8 @@ test("QueuedMutation - refetchOnSuccess can be disabled", async () => { const { client } = createTestClient(); let refetchCallCount = 0; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return _value; }, describe: "test mutation", @@ -432,8 +432,8 @@ test("QueuedMutation - refetch is called on error", async () => { const { client } = createTestClient(); let refetchCallCount = 0; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { throw new Error("mutation failed"); }, describe: "failing mutation", @@ -452,8 +452,8 @@ test("QueuedMutation - queued mutations are cancelled on error", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(10); if (id === "1") { @@ -486,8 +486,8 @@ test("QueuedMutation - rollbacks are called in reverse order on error", async () const { client } = createTestClient(); const rollbackOrder: number[] = []; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { throw new Error("mutation failed"); }, describe: "failing mutation", @@ -509,8 +509,8 @@ test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation const { client } = createTestClient(); const rollbackOrder: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, id: string) { + const mutation = client.defineBlocking({ + async mutate(id: string) { await delay(10); if (id === "fail") { throw new Error("mutation failed"); @@ -542,8 +542,8 @@ test("QueuedMutation - onRestore throws error if called after optimistic phase", const { client } = createTestClient(); let capturedOnRestore: ((cb: () => void) => void) | null = null; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return "result"; }, describe: "test mutation", @@ -573,8 +573,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase", const { client } = createTestClient(); let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return "result"; }, describe: "test mutation", @@ -603,8 +603,8 @@ test("QueuedMutation - onSuccess throws error if called after optimistic phase", test("QueuedMutation - error during optimistic update is rejected immediately", async () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return "result"; }, describe: "test mutation", @@ -625,8 +625,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call const { client } = createTestClient(); const rollbackOrder: number[] = []; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return "result"; }, describe: "test mutation", @@ -648,8 +648,8 @@ test("QueuedMutation - error during optimistic update rolls back registered call test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return value; }, describe: "test mutation", @@ -674,8 +674,8 @@ test("QueuedMutation - optimistic function receives args and helpers", async () let receivedArgs: unknown[] | undefined; let receivedHelpers: unknown | undefined; - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { return "result"; }, describe: "test mutation", @@ -697,8 +697,8 @@ test("QueuedMutation - refetch receives context and args", async () => { let receivedUserId: string | undefined; let receivedArgs: unknown[] | undefined; - const mutation = client.defineQueued({ - async mutate(_, _id: string, value: string) { + const mutation = client.defineBlocking({ + async mutate(_id: string, value: string) { return value; }, describe: "test mutation", @@ -719,8 +719,8 @@ test("QueuedMutation - notifies error on mutation failure", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { await delay(10); throw new Error("mutation failed"); }, @@ -747,8 +747,8 @@ test("QueuedMutation - multiple subscribers receive events", async () => { const tracker1 = createEventTracker(); const tracker2 = createEventTracker(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { await delay(5); return value; }, @@ -774,8 +774,8 @@ test("QueuedMutation - onSuccess is called before mutation resolves", async () = const { client } = createTestClient(); const callOrder: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return value; }, describe: "test mutation", @@ -804,8 +804,8 @@ test("QueuedMutation - result is passed to notification on success", async () => const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { await delay(5); return `result-${value}`; }, @@ -834,8 +834,8 @@ test("QueuedMutation - channel is reused for same key", async () => { const { client } = createTestClient(); const events: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { events.push(`mutate-${value}`); return value; }, @@ -859,8 +859,8 @@ test("QueuedMutation - channel is reused for same key", async () => { test("QueuedMutation - empty queue after all mutations complete", async () => { const { client } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { await delay(5); return value; }, @@ -893,8 +893,8 @@ test("QueuedMutation - multiple onSuccess callbacks are all called", async () => const { client } = createTestClient(); const results: string[] = []; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return value; }, describe: "test mutation", @@ -916,8 +916,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { const { client } = createTestClient(); let refetchCalled = false; - const mutation = client.defineQueued({ - async mutate(_, value: string) { + const mutation = client.defineBlocking({ + async mutate(value: string) { return value; }, describe: "test mutation", @@ -938,8 +938,8 @@ test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { test("QueuedMutation - refetch error after mutation failure is reported", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineQueued({ - async mutate(_, _value: string) { + const mutation = client.defineBlocking({ + async mutate(_value: string) { throw new Error("mutation failed"); }, describe: "failing mutation",