diff --git a/example/index.html b/example/index.html deleted file mode 100644 index 593e1a2c12d1829ed17215e75f627c2406f3dcd5..0000000000000000000000000000000000000000 --- a/example/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - React Mutation Demo - - -
- - - diff --git a/example/package.json b/example/package.json deleted file mode 100644 index abe5c67893ac5a9a0cfb0ed2777534c9c6296029..0000000000000000000000000000000000000000 --- a/example/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "private": true, - "type": "module", - "scripts": { - "dev": "vite" - }, - "dependencies": { - "@tanstack/react-query": "^5.90.20", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4" - } -} diff --git a/example/src/App.tsx b/example/src/App.tsx index ab99be9c72a92bdbc8f0e704c08f507b005dfc6d..fe1b0ded221f42d302f84168ea70719870cc3038 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -49,7 +49,7 @@ const queryCounter = queryOptions({ // await client.invalidateQueries(queryCounter); // }, // }); -const mutIncrement = mutationClient.defineDebounced({ +const mutIncrement = mutationClient.defineBatched({ mode: "debounce", time: 200, @@ -71,7 +71,7 @@ const mutIncrement = mutationClient.defineDebounced({ describe: "update counter", describeResult: ({ initial, current }) => { const delta = current - initial; - return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`; + return `Counter updated by ${delta > 0 ? "+" : ""}${delta}`; }, }); diff --git a/example/src/index.css b/example/src/index.css deleted file mode 100644 index b8902063d9ed5f0193f0d61046e975a183e3215e..0000000000000000000000000000000000000000 --- a/example/src/index.css +++ /dev/null @@ -1,198 +0,0 @@ -:root { - font-family: system-ui, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - --primary: #646cff; - --primary-hover: #535bf2; - --success: #4ade80; - --error: #ef4444; - --warning: #f59e0b; - --bg: #242424; - --surface: #1a1a1a; - --text: rgba(255, 255, 255, 0.87); - --text-muted: rgba(255, 255, 255, 0.6); -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -#root { - width: 100%; -} - -* { - box-sizing: border-box; -} -.app { - max-width: 800px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; - margin-bottom: 0.5rem; -} - -.subtitle { - color: var(--text-muted); - margin-bottom: 2rem; -} - -.counter-card { - background: var(--surface); - border-radius: 12px; - padding: 2rem; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.counter-display { - margin: 2rem 0; -} - -.count { - font-size: 4rem; - font-weight: bold; - margin: 0; - color: var(--primary); -} - -.mutation-info { - margin: 2rem 0; - min-height: 100px; - text-align: left; -} - -.status-badges { - display: flex; - gap: 0.5rem; - justify-content: center; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.badge { - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-size: 0.875rem; - font-weight: 500; -} - -.badge.idle { - background: rgba(148, 163, 184, 0.2); - color: #94a3b8; -} - -.badge.mutating { - background: rgba(245, 158, 11, 0.2); - color: var(--warning); -} - -.badge.success { - background: rgba(74, 222, 128, 0.2); - color: var(--success); -} - -.badge.error { - background: rgba(239, 68, 68, 0.2); - color: var(--error); -} - -.badge.pending { - background: rgba(100, 108, 255, 0.2); - color: var(--primary); - animation: pulse 2s infinite; -} - -.badge.optimistic { - background: rgba(168, 85, 247, 0.2); - color: #a855f7; -} - -.success-message { - padding: 1rem; - background: rgba(74, 222, 128, 0.1); - border: 1px solid rgba(74, 222, 128, 0.3); - border-radius: 8px; - color: var(--success); - margin-top: 1rem; -} - -.error-message { - padding: 1rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 8px; - color: var(--error); - margin-top: 1rem; -} - -.button-group { - display: flex; - gap: 1rem; - justify-content: center; - flex-wrap: wrap; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - cursor: pointer; - transition: all 0.25s; -} - -.primary-button, -.mutation-button { - background-color: var(--primary); - color: white; -} - -.primary-button:hover:not(:disabled), -.mutation-button:hover:not(:disabled) { - background-color: var(--primary-hover); -} - -.secondary-button { - background-color: transparent; - border-color: var(--text-muted); - color: var(--text); -} - -.secondary-button:hover:not(:disabled) { - border-color: var(--primary); -} - -button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} diff --git a/example/src/main.tsx b/example/src/main.tsx deleted file mode 100644 index ef474bf64cfb4374e19484541cdec71a6d13c6dd..0000000000000000000000000000000000000000 --- a/example/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; -import "./index.css"; - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/example/tsconfig.json b/example/tsconfig.json deleted file mode 100644 index f7a694d2fba53195e12bcfa4b4fea180949cc913..0000000000000000000000000000000000000000 --- a/example/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "lib": ["ESNext", "DOM"], - "rootDir": "../", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "moduleResolution": "nodenext", - "moduleDetection": "force", - "noEmit": true, - "allowImportingTsExtensions": true, - "jsx": "react-jsx", - "paths": { - "@clo/react-mutation": ["../src/mod.ts"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} diff --git a/example/vite.config.ts b/example/vite.config.ts deleted file mode 100644 index 54e63f9ae0a78e68c130fbfdb7e040244a8e7ab2..0000000000000000000000000000000000000000 --- a/example/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import path from "node:path"; - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - "@clo/react-mutation": path.resolve(__dirname, "../src"), - }, - }, -}); diff --git a/jsr.json b/jsr.json index e81b90dfeda66733df5e35779c87cb29297c387c..90ed039c9595323b459889470b8f00e9e82541b5 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/readme.md b/readme.md index c96353f60368c38aebf0b234fb85a3a0559423d9..afdb0dc47fc63881b49592da1735e6a8b49ab127 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # `@clo/react-mutation` +Install via [JSR](https://jsr.io/@clo/react-mutation): `npx jsr add @clo/react-mutation` + ## Motivation At work, we found React Query, with a few helper functions, to be extremely @@ -10,38 +12,31 @@ patterns and verbose code that is hard to review. The primary gains React Mutation provides are -- **Automatic error handling**. If a `useMutate` hook does not observe +- **Automatic result 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 - of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers) show this power in more detail. -- Debounced Mutations are just so awesome to use. + of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers) + shows this power in more detail. +- Easy debouncing and batching utilities. ## Usage -This library declares two kinds of mutations. Each kind has different behavior -around concurrent operations. - -- [**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. -- [**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. - React Mutation starts with a `MutationClient`, which shares global state for an application. ```ts +import { QueryClient } from "@tanstack/react-query"; +import { MutationClient } from "@clo/react-mutation"; +import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation"; +import { showToastUI } from "..."; + const queryClient = new QueryClient(); export const mutations = new MutationClient({ // All properties in `context` are available within every function. context: { client: queryClient, // Can add any easy helpers for your codebase. - // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet`) + // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`) get: (k: QueryKey) => client.getQueryData(k), }, @@ -62,17 +57,13 @@ export const mutations = new MutationClient({ reportSuccess(userFriendlySuccessMessage: string) { showToastUI("success", userFriendlyErrorMessage); }, -}) - +}); ``` -### Blocking Mutations +### Declaring Mutations -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. +With a mutation client, you can declare mutations with `mutations.define()`. +Start with the API call code, and then add an optimistic updater function. ```tsx const queryItemList = queryOptions({ ... }); @@ -80,19 +71,27 @@ const queryItem = (id: string) => queryOptions({ ... }); // The convention is to name handlers starting with `mut` const mutDeleteItem = mutations.defineBlocking({ - // `mutate` comes first, is only worried about syncing with the backend. + // `mutate` comes first (for type inference), and + // is only worried about syncing with the backend. async mutate(id: string) { const response = await fetch(`/items/${id}`, { method: "delete" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); }, - optimistic({ client, get, helpers, args: [id] }) { + // `optimistic` is provided a `helpers` object which implement automatic rollbacks. + optimistic({ client, get, helpers, args: [id], onSuccess }) { // 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); + + onSuccess((result) => { + // in general case, you may want to apply a success update + }); }, + // These strings are shown in error/success messages, called *after* optimistic state is applied. // Example: `Could not {description}` describe({ get, args: [id] }) { const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; @@ -102,8 +101,8 @@ const mutDeleteItem = mutations.defineBlocking({ describeResult: ({ get, args: [id] }) => `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, - // Since the optimistic handler is perfect, there is no need - // to refetch any data once a success case is hit. + // Since the optimistic handler is perfect, there is no need to refetch any + // data once a success case is hit. This defaults to false for simplicity. refetchOnSuccess: false, }); @@ -111,8 +110,8 @@ const mutDeleteItem = mutations.defineBlocking({ // indicated through UI toasts from the mutation client. export function Example({ id }: { id: string }) { const { data: list } = useSuspenseQuery(queryItemList); - const { run } = useMutate(mutDeleteItem); - + const { run, /* isPending, result, error, ... */ } = useMutate(mutDeleteItem); + return list.map((id) =>
  • @@ -120,63 +119,6 @@ export function Example({ id }: { id: string }) { } ``` -### Debounced Mutations - -A debounced mutation is defined with `mutations.defineDebounced`. - -```tsx -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 snapshotted *before* calling `optimistic`, and then again after - // the timer. If the snapshots differ, then `commit` function is called. - getValue: ({ get }) => get(queryCounter)?.title ?? "", - - // Split different `id`s into their own debounces. - key: ({ args: [id] }) => id, - - // 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", - body: JSON.stringify({ title: current }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - }, - - 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 - } - -} -``` ### Optimistic Updates @@ -189,7 +131,7 @@ The `optimistic` function is given an object with the following APIs - `onRestore` - add a callback to revert your optimistic update - `onRefetch` - add a callback to fetch data after a success -### React Query Optimistic Helpers +#### 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 @@ -235,8 +177,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list - `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. +- Queries that are complex objects. Each function takes a type-safe json path to + evaluate, but this system currently has type bugs and is being improved. - `objSet` - set a property - `objSetMany` - set many properties at once - `objIncrement` - increment a number @@ -247,3 +189,164 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list - `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 + +### Debouncing + +By default, a mutation will block the UI (by setting isPending). If you add +`debounceMs`, the mutation will no longer set isPending. Multiple mutations + +```tsx +const mutUpdateField = mutations.define({ + async mutate(id: string, value: string) { /* mutation */ }, + optimistic({ args: [id, value], helpers }) { + helpers.objSet(queryItem(id), ["value"], value); + }, + // (...describe functions...) + + // debounce for 0.5 seconds, grouping items on their `id` + debounceMs: 500, + key: ({ args: [id] }) => id, +}); + +// React example - Auto-saving text field +function Item({ id }: { id: string }) { + const { data: item } = useSuspenseQuery(queryItem(id)); + const { run, isSuccess } = useMutate(); + + return ( + { + mutUpdateField.run(id, e.target.value); + }} + /> + {} + ); +} +``` + +### Calling Mutations + +Three methods exist for calling mutations: + +- Directly on the mutation: `mutDoAction.run()` +- From a React component: `useMutate(mutDoAction)` +- From a React Element: `` + +#### The `useMutate` Hook + +The `useMutate(null | Mutation)` react hook returns an object with the following properties. + +- `run` (Function) this starts the mutation. +- `clear` (Function) clear the status of sucess or error states. +- `isPending` (boolean) if a loading indicator should be visible. +- `isSuccess` (boolean) if the mutation has succeeded. +- `result` (Result or undefined) the successful result of the mutation. +- `isError` (boolean) if the mutation failed. +- `errorMessage` (string or undefined) a friendly error message. +- `error` (unknown) the error value of the mutation. +- `isMutating` (boolean) if a mutation function is currently running. +- `isOptimisticData` (boolean) if cache data is optimistic. +- `status`: a string enum of the mutation status. + +The object uses getters to determine which fields should be subscribed to for +re-renders, but this is also used to determine how errors should be propagated. +If the error is observed by the component, then React Mutation will know not to +invoke the global error handler. Same for success. + +```ts +const { errorMeseage, isSuccess, run: run1 } = useMutate(...); // local handling in the form +const { run: run2 } = useMutate(...); // global handling with alerts + +return ( + <> + + {isSuccess ? "you win!" : errorMeseage} + + + <> +); +``` + +#### Mutation Buttons + +You can wrap your button component with `createMutationButton` to make it support mutations + +```tsx +function MutationButtonBase({ + isPending, + disabled, + children, + ...args +}: { + isPending: boolean; + iconButton?: boolean; +} & ButtonProps) { + return ( + + ); +} +export const MutationButton = createMutationButton(MutationButtonBase); +``` + +It can now be used for easy mutations: + +```tsx +<> + {/* Static Arguments */} + Follow + + {/* Dynamic Arguments */} + { + if (Math.random() < 0.5) e.preventDefault(); // prevent the submit + return [userId, messageContent]; + }}>Send Message + +``` + + + +### Batched Mutations + +Each call to the mutation applies new optimistic state on top of the previous, +and after a debounce / throttle, the new optimistic state is committed to the +API. UI never shows a pending state for these. This works great for toggle buttons +and any other state where you'd like to define an optimistic state + +In many places, similar behavior can be achieved with standard mutations and its +`debounceMs` field. + +```tsx +const mutToggleFollow = mutations.defineBatched({ + // Think of your mutator in terms of how it applies optimistic state. + optimistic({ helpers }, userId: string) { + helpers.objToggle(queryUser(userId), ["following"]); + }, + // 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(queryUser(id))?.following, + + // Split different `id`s into their own batches. + key: ({ args: [id] }) => id, + + // 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", + body: JSON.stringify({ title: current }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }, + + describe: ({ get, args: [id] }) => + `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`, + describeResult: ({ get, args: [id] }) => + `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, +}); +``` diff --git a/src/blocking.ts b/src/blocking.ts index 3900f0ff47fcc84c24f9f3c4ca816cde202fa5c7..e3667c8dea1ce11a527a0dc89678ac3ad96f1a33 100644 --- a/src/blocking.ts +++ b/src/blocking.ts @@ -9,7 +9,7 @@ import { message as errMessage } from "@clo/lib/error.ts"; * @template Result - the result of the API call * @template Config - global values and helpers from `MutationContext` */ -export interface BlockingMutationOptions< +export interface MutationOptions< Args extends unknown[], Result, Config extends MutationClientConfig, @@ -34,12 +34,17 @@ export interface BlockingMutationOptions< * Used in success messages. * Phrase it as a complete success message, e.g., "Deleted item successfully" */ - describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null; + describeResult: + | string + | ((context: Config["context"] & { args: Args; result: Result }) => string) + | null; /** * Specifying the optimistic strategy is required. To disable, pass an empty * function with a comment to document why it isn't needed. */ - optimistic: (context: BlockingOptimisticContext) => void; + optimistic: ( + context: OptimisticContext, + ) => void; /** * Refetch all of the data this mutation could have affected. * Normally, optimistic helpers will perform @@ -57,9 +62,16 @@ export interface BlockingMutationOptions< * but optimistic updates will apply instantly. */ key?: (context: Config["context"] & { args: Args }) => string | string[]; + /** + * Enable debouncing with "last call wins" behavior. When rapid calls arrive, + * the previous optimistic update is rolled back and the new one applied. + * + * All pending promises resolve with the final result. + */ + debounceMs?: number; } -export type BlockingOptimisticContext< +export type OptimisticContext< Args extends unknown[], Result, Config extends MutationClientConfig, @@ -74,14 +86,31 @@ export type BlockingOptimisticContext< onRefetch: (cb: () => Promise) => void; }; -interface BlockingChannel { +interface PendingDebouncedState { + /** Arguments from the most recent call */ + args: Args; + /** Number of rollbacks the most recent call added */ + rollbackCount: number; + /** All pending promises from all superseded calls */ + pending: Array<{ + resolve: (result: Result) => void; + reject: (error: unknown) => void; + }>; + /** Success callbacks from the most recent call */ + onSuccess: Array<(result: Result) => void>; +} + +interface Channel { listeners: Set<(update: MutationEvent) => void>; - status: "idle" | "mutating" | "refetching"; + status: "idle" | "waiting" | "mutating" | "refetching"; rollbacks: Array<() => void>; refetches: Array<() => Promise>; queue: Array>; // Shared optimistic helpers instance for the channel helpers: OptimisticHelpers | null; + // Debounce state (only used if debounce option is set) + debounceTimer: ReturnType | null; + pendingDebounced: PendingDebouncedState | null; } interface Item { @@ -97,14 +126,17 @@ export class BlockingMutation< Result, Config extends MutationClientConfig, > implements Mutation { - #options: BlockingMutationOptions; + #options: MutationOptions; #client: MutationClientFromConfig; - #channels: Map> = new Map(); + #channels: Map< + string, + Channel + > = new Map(); client: MutationClientFromConfig; constructor( client: MutationClient, - options: BlockingMutationOptions, + options: MutationOptions, ) { this.#options = options; this.#client = client; @@ -128,6 +160,8 @@ export class BlockingMutation< refetches: [], queue: [], helpers: null, + debounceTimer: null, + pendingDebounced: null, }; this.#channels.set(key, channel); } @@ -144,7 +178,7 @@ export class BlockingMutation< } #notify( - channel: BlockingChannel, + channel: Channel, status: MutationEvent["status"], result: Result | null = null, error: unknown = null, @@ -153,14 +187,30 @@ export class BlockingMutation< channel.listeners.forEach((cb) => cb(event)); } - #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.#channels.delete(key); + #setIdle( + key: string, + channel: Channel, + ) { + // Check if there are pending debounced calls waiting + if (channel.pendingDebounced !== null) { + // Stay in waiting state + channel.status = "waiting"; + this.#notify(channel, "waiting", null, null); + } else { + // Normal idle transition + channel.status = "idle"; + // Discard any unconsumed refetch callbacks + channel.refetches = []; + this.#notify(channel, "idle", null, null); + // Clean up the channel if there are no listeners + if (channel.listeners.size === 0) { + // Clear any pending timers before deleting the channel + if (channel.debounceTimer !== null) { + clearTimeout(channel.debounceTimer); + channel.debounceTimer = null; + } + this.#channels.delete(key); + } } } @@ -192,7 +242,9 @@ export class BlockingMutation< this.#client.reportSuccess(message); } }).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); }); } @@ -207,6 +259,11 @@ export class BlockingMutation< const key = this.key(args); const channel = this.#getOrPutChannel(key); + // Check if debouncing is enabled + if (this.#options.debounceMs !== undefined) { + return this.#runDebouncedAndReturn(args, key, channel); + } + // Create shared optimistic helpers instance for the channel if it doesn't exist if (channel.helpers === null) { const onRefetch = (cb: () => Promise) => { @@ -285,7 +342,10 @@ export class BlockingMutation< return promise; } - #executeNext(key: string, channel: BlockingChannel) { + #executeNext( + key: string, + channel: Channel, + ) { const item = channel.queue.shift(); if (!item) { this.#setIdle(key, channel); @@ -316,7 +376,9 @@ export class BlockingMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + const message = `Failed to refetch after ${ + this.describe(...args) + }: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); @@ -358,7 +420,9 @@ export class BlockingMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + const message = `Failed to refetch after ${ + this.describe(...args) + }: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); @@ -369,4 +433,178 @@ export class BlockingMutation< reject(error); }); } + + #runDebouncedAndReturn( + args: Args, + key: string, + channel: Channel, + ): Promise { + // If there's a pending debounced call, roll it back + if (channel.pendingDebounced) { + this.#rollbackPendingDebounced(channel); + } + + // Create shared helpers if needed (same as current implementation) + if (channel.helpers === null) { + const onRefetch = (cb: () => Promise) => { + channel.refetches.push(cb); + }; + channel.helpers = this.#client.getOptimisticHelpers({ + onRestore: (cb: () => void) => { + channel.rollbacks.push(cb); + }, + onRefetch, + }); + } + + // Apply optimistic update (same logic as current runAndReturn) + const onSuccess: Array<(result: Result) => void> = []; + let expired = false; + let rollbacks = 0; + const onRestore = (cb: () => void) => { + if (expired) { + throw new Error( + "Can only call onRestore from within the optimistic update function.", + ); + } + channel.rollbacks.push(cb); + rollbacks += 1; + }; + + try { + this.#options.optimistic({ + args, + helpers: channel.helpers, + onRestore, + onSuccess(cb) { + if (expired) { + throw new Error( + "Can only call onSuccess from within the optimistic update function.", + ); + } + onSuccess.push(cb); + }, + onRefetch(cb) { + if (expired) { + throw new Error( + "Can only call onRefetch from within the optimistic update function.", + ); + } + channel.refetches.push(cb); + }, + }); + } catch (error) { + expired = true; + // Roll back the rollbacks we just added + let next; + while ( + next = + channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] + ) { + next(); + } + return Promise.reject(error); + } + expired = true; + + // Create promise for this call + const { promise, resolve, reject } = Promise.withResolvers(); + + // Store or update pending debounced state + if (channel.pendingDebounced === null) { + // First debounced call + channel.pendingDebounced = { + args, + rollbackCount: rollbacks, + pending: [{ resolve, reject }], + onSuccess, + }; + + // Set status to waiting + channel.status = "waiting"; + this.#notify(channel, "waiting"); + } else { + // Subsequent debounced call - update state + channel.pendingDebounced.args = args; + channel.pendingDebounced.rollbackCount = rollbacks; + channel.pendingDebounced.pending.push({ resolve, reject }); + channel.pendingDebounced.onSuccess = onSuccess; + // Status stays "waiting" + } + + // Clear existing timer + if (channel.debounceTimer !== null) { + clearTimeout(channel.debounceTimer); + } + + // Start new timer + channel.debounceTimer = setTimeout(() => { + this.#enqueueDebouncedCall(key, channel); + }, this.#options.debounceMs); + + return promise; + } + + #rollbackPendingDebounced( + channel: Channel, + ) { + if (!channel.pendingDebounced) return; + + const { rollbackCount } = channel.pendingDebounced; + + // Roll back this call's optimistic updates (in reverse order) + // Remove from the end of the rollbacks array + for (let i = 0; i < rollbackCount; i++) { + const rollback = channel.rollbacks.pop(); + if (rollback) rollback(); + } + + // Note: We do NOT reject the promises here + // They will all resolve when the final call completes + } + + #enqueueDebouncedCall( + key: string, + channel: Channel, + ) { + // Clear timer + channel.debounceTimer = null; + + // Safety check + if (!channel.pendingDebounced) { + this.#setIdle(key, channel); + return; + } + + const { args, rollbackCount, pending, onSuccess } = + channel.pendingDebounced; + channel.pendingDebounced = null; + + // Create wrapper resolve/reject that resolves ALL pending promises + const { + promise: wrapperPromise, + resolve: wrapperResolve, + reject: wrapperReject, + } = Promise.withResolvers(); + + wrapperPromise.then( + (result) => pending.forEach((p) => p.resolve(result)), + (error) => pending.forEach((p) => p.reject(error)), + ); + + // Add to queue (same structure as regular blocking mutation) + channel.queue.push({ + args, + rollbacks: rollbackCount, + onSuccess, + resolve: wrapperResolve, + reject: wrapperReject, + }); + + // If queue was idle/waiting, start execution + if (channel.status === "idle" || channel.status === "waiting") { + this.#executeNext(key, channel); + } + // Otherwise, it will execute when the current item finishes + } } diff --git a/src/client.ts b/src/client.ts index 1d7a8394d7e5bc911aa6a7579a40021b1a760c75..34325516055853f896b6734e17d22180392b5a1b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,8 @@ -import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; -import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts"; +import { + DebouncedMutation, + type DebouncedMutationOptions, +} from "./debounced.ts"; +import { BlockingMutation, type MutationOptions } from "./blocking.ts"; import type { Mutation } from "./types.ts"; export interface MutationClientConfig { @@ -63,12 +66,10 @@ export class MutationClient< } /** - * 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. + * Define a standard mutation. */ - defineBlocking( - options: BlockingMutationOptions< + define( + options: MutationOptions< Args, Result, { context: Context; optimisticHelpers: OptimisticHelpers } @@ -82,13 +83,13 @@ export class MutationClient< } /** - * Define a debounced mutation. Each call to the mutation applies new optimistic + * Define a batched mutation. Each call to the mutation applies new optimistic * state, and after a debounce or throttle, the new optimistic state is * committed to the API. UI never shows a pending state for debounced mutations. This * works great for auto-saving input fields, follow buttons, and is preferred * whenever possible. */ - defineDebounced( + defineBatched( options: DebouncedMutationOptions< Args, Result, diff --git a/src/mod.ts b/src/mod.ts index df9eccb473ce97cc4fb3e9d044bf729185bfb76d..4a58684c55e252c62e57d3c11cec66e3c1530e7d 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,7 +1,4 @@ -export type { - BlockingMutationOptions, - BlockingOptimisticContext, -} from "./blocking.ts"; +export type { MutationOptions, OptimisticContext } from "./blocking.ts"; export type { DebouncedCommitContext, DebouncedMutationOptions, diff --git a/test/blocking.test.ts b/test/blocking.test.ts index 6ccf3be0acfa14be711dcf835cdea7b843b1d14c..39bfe6c3ce2840a0eb2d4a3ffcac9de61004c50a 100644 --- a/test/blocking.test.ts +++ b/test/blocking.test.ts @@ -49,7 +49,7 @@ test("BlockingMutation - basic mutation success", async () => { let mutateCallCount = 0; let refetchCallCount = 0; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { mutateCallCount++; await delay(10); @@ -78,7 +78,7 @@ test("BlockingMutation - basic mutation success", async () => { test("BlockingMutation - run() catches errors", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { throw new Error("mutation failed"); }, @@ -98,7 +98,7 @@ test("BlockingMutation - run() catches errors", async () => { test("BlockingMutation - runAndReturn() rejects on error", async () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { throw new Error("mutation failed"); }, @@ -119,7 +119,7 @@ test("BlockingMutation - optimistic updates are applied immediately", async () = const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_key: string, value: string) { await delay(50); return value; @@ -147,7 +147,7 @@ test("BlockingMutation - rollback on error", async () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_key: string, _value: string) { await delay(10); throw new Error("mutation failed"); @@ -171,7 +171,7 @@ test("BlockingMutation - onSuccess callback is called", async () => { const { client } = createTestClient(); const successResults: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return `result-${value}`; }, @@ -194,7 +194,7 @@ test("BlockingMutation - mutations with same key execute serially", async () => const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(20); @@ -226,7 +226,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(20); @@ -256,7 +256,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy test("BlockingMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { return id; }, @@ -276,7 +276,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => { test("BlockingMutation - key() defaults to 'shared' when no key function", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { return id; }, @@ -292,7 +292,7 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () => test("BlockingMutation - key() can return array", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_userId: string, _itemId: string) { return "result"; }, @@ -315,7 +315,7 @@ test("BlockingMutation - key() can return array", () => { test("BlockingMutation - describe() with string", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return value; }, @@ -331,7 +331,7 @@ test("BlockingMutation - describe() with string", () => { test("BlockingMutation - describe() with function", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { return id; }, @@ -349,7 +349,7 @@ test("BlockingMutation - describe() with function", () => { test("BlockingMutation - describe() receives context", () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { return id; }, @@ -371,7 +371,7 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { await delay(10); return `result-${value}`; @@ -401,7 +401,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { await delay(10); return value; @@ -429,7 +429,7 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => { const { client } = createTestClient(); let refetchCallCount = 0; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return _value; }, @@ -451,7 +451,7 @@ test("BlockingMutation - refetch is called on error", async () => { const { client } = createTestClient(); let refetchCallCount = 0; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { throw new Error("mutation failed"); }, @@ -472,7 +472,7 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => { const { client } = createTestClient(); const executionOrder: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { executionOrder.push(`start-${id}`); await delay(10); @@ -507,7 +507,7 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async const { client } = createTestClient(); const rollbackOrder: number[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { throw new Error("mutation failed"); }, @@ -531,7 +531,7 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati const { client } = createTestClient(); const rollbackOrder: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(id: string) { await delay(10); if (id === "fail") { @@ -565,7 +565,7 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase const { client } = createTestClient(); let capturedOnRestore: ((cb: () => void) => void) | null = null; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return "result"; }, @@ -597,7 +597,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase const { client } = createTestClient(); let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return "result"; }, @@ -628,7 +628,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase test("BlockingMutation - error during optimistic update is rejected immediately", async () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return "result"; }, @@ -651,7 +651,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca const { client } = createTestClient(); const rollbackOrder: number[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return "result"; }, @@ -675,7 +675,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return value; }, @@ -702,7 +702,7 @@ test("BlockingMutation - optimistic function receives args and helpers", async ( let receivedArgs: unknown[] | undefined; let receivedHelpers: unknown | undefined; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { return "result"; }, @@ -726,7 +726,7 @@ test("BlockingMutation - refetch receives context and args", async () => { let receivedUserId: string | undefined; let receivedArgs: unknown[] | undefined; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_id: string, value: string) { return value; }, @@ -749,7 +749,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => { const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { await delay(10); throw new Error("mutation failed"); @@ -778,7 +778,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => { const tracker1 = createEventTracker(); const tracker2 = createEventTracker(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { await delay(5); return value; @@ -806,7 +806,7 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async () const { client } = createTestClient(); const callOrder: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return value; }, @@ -837,7 +837,7 @@ test("BlockingMutation - result is passed to notification on success", async () const { client } = createTestClient(); const tracker = createEventTracker(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { await delay(5); return `result-${value}`; @@ -868,7 +868,7 @@ test("BlockingMutation - channel is reused for same key", async () => { const { client } = createTestClient(); const events: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { events.push(`mutate-${value}`); return value; @@ -894,7 +894,7 @@ test("BlockingMutation - channel is reused for same key", async () => { test("BlockingMutation - empty queue after all mutations complete", async () => { const { client } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { await delay(5); return value; @@ -929,7 +929,7 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async () const { client } = createTestClient(); const results: string[] = []; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return value; }, @@ -953,7 +953,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { const { client } = createTestClient(); let refetchCalled = false; - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(value: string) { return value; }, @@ -976,7 +976,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { test("BlockingMutation - refetch error after mutation failure is reported", async () => { const { client, errors } = createTestClient(); - const mutation = client.defineBlocking({ + const mutation = client.define({ async mutate(_value: string) { throw new Error("mutation failed"); }, @@ -1004,3 +1004,452 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn "refetch also failed", ); }); + +// ============================================================================ +// Debouncing Tests +// ============================================================================ + +test("BlockingMutation - debounce: basic debounced execution", async () => { + const { client } = createTestClient(); + testStore.clear(); + let mutateCallCount = 0; + + const mutation = client.define({ + async mutate(key: string, value: string) { + mutateCallCount++; + await delay(10); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + const [key, value] = args; + helpers.setValue(key, value); + }, + async refetch() {}, + debounceMs: 50, + }); + + const promise = mutation.runAndReturn("key1", "value1"); + + // Optimistic update should be applied immediately + assertEquals(testStore.get("key1"), "value1"); + + // Mutation should not have executed yet + assertEquals(mutateCallCount, 0); + + // Wait for debounce to complete + const result = await promise; + assertEquals(result, "result-value1"); + assertEquals(mutateCallCount, 1); +}); + +test("BlockingMutation - debounce: last call wins with multiple rapid calls", async () => { + const { client } = createTestClient(); + testStore.clear(); + let mutateCallCount = 0; + const mutateArgs: Array<[string, string]> = []; + + const mutation = client.define({ + async mutate(key: string, value: string) { + mutateCallCount++; + mutateArgs.push([key, value]); + await delay(10); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + const [key, value] = args; + helpers.setValue(key, value); + }, + async refetch() {}, + debounceMs: 50, + }); + + // Make three rapid calls + const promise1 = mutation.runAndReturn("key1", "a"); + const promise2 = mutation.runAndReturn("key1", "b"); + const promise3 = mutation.runAndReturn("key1", "c"); + + // Last optimistic update should be applied + assertEquals(testStore.get("key1"), "c"); + + // Wait for debounce to complete + const [result1, result2, result3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + // All promises should resolve with the same result + assertEquals(result1, "result-c"); + assertEquals(result2, "result-c"); + assertEquals(result3, "result-c"); + + // Only one mutation should have executed, with the last args + assertEquals(mutateCallCount, 1); + assertEquals(mutateArgs, [["key1", "c"]]); +}); + +test("BlockingMutation - debounce: optimistic rollback and reapply", async () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.define({ + async mutate(key: string, value: string) { + await delay(10); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + const [key, value] = args; + helpers.setValue(key, value); + // Add a second value to test multiple rollbacks + helpers.setValue(`${key}-2`, `${value}-2`); + }, + async refetch() {}, + debounceMs: 50, + }); + + // First call sets two values + mutation.runAndReturn("key1", "a"); + assertEquals(testStore.get("key1"), "a"); + assertEquals(testStore.get("key1-2"), "a-2"); + + // Second call should rollback first call's optimistic and apply its own + const promise = mutation.runAndReturn("key1", "b"); + assertEquals(testStore.get("key1"), "b"); + assertEquals(testStore.get("key1-2"), "b-2"); + + // Wait for completion + await promise; + await delay(20); + + // Final values should still be from the last call + assertEquals(testStore.get("key1"), "b"); + assertEquals(testStore.get("key1-2"), "b-2"); +}); + +test("BlockingMutation - debounce: timer reset behavior", async () => { + const { client } = createTestClient(); + let mutateCallCount = 0; + + const mutation = client.define({ + async mutate(value: string) { + mutateCallCount++; + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + debounceMs: 100, + }); + + // Call at t=0 + const promise1 = mutation.runAndReturn("first"); + + // Call at t=50 (should reset timer) + await delay(50); + const promise2 = mutation.runAndReturn("second"); + + // At t=100, mutation should NOT have executed yet + await delay(50); + assertEquals(mutateCallCount, 0); + + // At t=150, mutation should execute + await delay(50); + await Promise.all([promise1, promise2]); + + assertEquals(mutateCallCount, 1); +}); + +test("BlockingMutation - debounce: integration with blocking queue", async () => { + const { client } = createTestClient(); + const executionOrder: string[] = []; + + const mutation = client.define({ + async mutate(id: string) { + executionOrder.push(`start-${id}`); + await delay(30); + executionOrder.push(`end-${id}`); + return `result-${id}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + debounceMs: 30, + key: () => "shared", + }); + + // Start a debounced call that will enter queue first + const promise1 = mutation.runAndReturn("first"); + + // While it's waiting in debounce, fire more debounced calls + await delay(10); + const promise2 = mutation.runAndReturn("second"); + const promise3 = mutation.runAndReturn("third"); + + // Wait for all to complete + await Promise.all([promise1, promise2, promise3]); + + // Only third should execute (last call wins) + assertEquals(executionOrder, [ + "start-third", + "end-third", + ]); +}); + +test("BlockingMutation - debounce: error during optimistic update", async () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.define({ + async mutate(_value: string) { + return "result"; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + const [value] = args; + if (value === "error") { + throw new Error("optimistic error"); + } + helpers.setValue("key", value); + }, + async refetch() {}, + debounceMs: 50, + }); + + // Call that throws during optimistic + await assertRejects( + () => mutation.runAndReturn("error"), + Error, + "optimistic error", + ); + + // Store should be empty + assertEquals(testStore.has("key"), false); + + // Subsequent successful call should work + const promise = mutation.runAndReturn("good"); + assertEquals(testStore.get("key"), "good"); + await promise; +}); + +test("BlockingMutation - debounce: status transitions", async () => { + const { client } = createTestClient(); + const { events, callback } = createEventTracker(); + + const mutation = client.define({ + async mutate(value: string) { + await delay(20); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + await delay(10); + }, + debounceMs: 50, + }); + + const key = mutation.key(["test"]); + const unsubscribe = mutation.subscribe(key, callback); + + // First call should transition to waiting + mutation.runAndReturn("test"); + await delay(10); + assertEquals(events[events.length - 1].status, "waiting"); + + // Wait for debounce and mutation to complete + await delay(80); + + // Should have transitioned: waiting -> mutating -> refetching -> idle + const statuses = events.map((e) => e.status); + assertEquals(statuses, ["waiting", "mutating", "refetching", "idle"]); + + unsubscribe(); +}); + +test("BlockingMutation - debounce: debounced call executes after queue error", async () => { + const { client } = createTestClient(); + let callCount = 0; + + const mutation = client.define({ + async mutate(id: string) { + callCount++; + if (id === "fail") { + throw new Error("mutation failed"); + } + await delay(20); + return `result-${id}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + await delay(10); + }, + debounceMs: 50, + key: () => "shared", + }); + + // Start a call that will fail (enters debounce) + const promise1 = mutation.runAndReturn("fail"); + + // Immediately override with a successful call (last call wins) + const promise2 = mutation.runAndReturn("success"); + + // Both promises should resolve with the same successful result + // (because debouncing causes "last call wins") + const result1 = await promise1; + const result2 = await promise2; + + assertEquals(result1, "result-success"); + assertEquals(result2, "result-success"); + assertEquals(callCount, 1); // Only one call executed +}); + +test("BlockingMutation - debounce: all promises resolve together", async () => { + const { client } = createTestClient(); + const resolvedAt: number[] = []; + + const mutation = client.define({ + async mutate(_id: string, value: string) { + await delay(20); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + debounceMs: 50, + }); + + // Create three rapid calls + const promise1 = mutation.runAndReturn("id", "a").then((result) => { + resolvedAt.push(Date.now()); + return result; + }); + const promise2 = mutation.runAndReturn("id", "b").then((result) => { + resolvedAt.push(Date.now()); + return result; + }); + const promise3 = mutation.runAndReturn("id", "c").then((result) => { + resolvedAt.push(Date.now()); + return result; + }); + + const results = await Promise.all([promise1, promise2, promise3]); + + // All should resolve with the same value + assertEquals(results, ["result-c", "result-c", "result-c"]); + + // All should resolve at approximately the same time (within 10ms) + assertEquals(resolvedAt.length, 3); + const maxDiff = Math.max(...resolvedAt) - Math.min(...resolvedAt); + assertEquals(maxDiff < 10, true); +}); + +test("BlockingMutation - debounce: cleanup on channel deletion", async () => { + const { client } = createTestClient(); + + const mutation = client.define({ + async mutate(value: string) { + await delay(10); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + debounceMs: 100, + }); + + const key = mutation.key(["test"]); + + // Subscribe and unsubscribe to create and delete the channel + const unsubscribe = mutation.subscribe(key, () => {}); + + // Start a debounced call + mutation.runAndReturn("test"); + await delay(10); + + // Unsubscribe while debounce is pending + unsubscribe(); + + // The timer should still fire and the mutation should complete + await delay(120); + + // No errors should have occurred + // (If the timer wasn't cleaned up properly, we might see issues) +}); + +test("BlockingMutation - debounce: multiple keys debounce independently", async () => { + const { client } = createTestClient(); + const mutateArgs: string[] = []; + + const mutation = client.define({ + async mutate(id: string) { + mutateArgs.push(id); + await delay(10); + return `result-${id}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + debounceMs: 50, + key: ({ args }) => args[0], + }); + + // Rapid calls to different keys + const promise1a = mutation.runAndReturn("key1"); + const promise1b = mutation.runAndReturn("key1"); + const promise2a = mutation.runAndReturn("key2"); + const promise2b = mutation.runAndReturn("key2"); + + await Promise.all([promise1a, promise1b, promise2a, promise2b]); + + // Should have executed once per key + assertEquals(mutateArgs.sort(), ["key1", "key2"]); +}); + +test("BlockingMutation - debounce: onSuccess callbacks from last call only", async () => { + const { client } = createTestClient(); + const successResults: string[] = []; + + const mutation = client.define({ + async mutate(value: string) { + await delay(10); + return `result-${value}`; + }, + describe: "debounced mutation", + describeResult: "Success", + optimistic({ args, onSuccess }) { + const [value] = args; + onSuccess((result) => { + successResults.push(`${value}->${result}`); + }); + }, + async refetch() {}, + debounceMs: 50, + }); + + // Make three rapid calls with different onSuccess callbacks + await Promise.all([ + mutation.runAndReturn("a"), + mutation.runAndReturn("b"), + mutation.runAndReturn("c"), + ]); + + await delay(20); + + // Only the last call's onSuccess should have been called + assertEquals(successResults, ["c->result-c"]); +}); diff --git a/test/debounced.test.ts b/test/debounced.test.ts index 68139a968041fc8eb74ec2c645fd01f838cfbfd9..de2eed583613c614f8a099a2464a7c27f6d7e15b 100644 --- a/test/debounced.test.ts +++ b/test/debounced.test.ts @@ -69,7 +69,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => { let commitCallCount = 0; let refetchCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -103,7 +103,7 @@ test("DebouncedMutation - run() catches errors", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -131,7 +131,7 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -166,7 +166,7 @@ test("DebouncedMutation - debounce batches rapid calls", async () => { let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -209,7 +209,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => { let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -259,7 +259,7 @@ test("DebouncedMutation - debounce separates batches after timeout", async () => let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -305,7 +305,7 @@ test("DebouncedMutation - throttle commits immediately on first call", async () let commitTime = 0; const startTime = Date.now(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -336,7 +336,7 @@ test("DebouncedMutation - throttle batches calls within time window", async () = let commitCallCount = 0; const commitArgs: Array<{ initial: number; current: number }> = []; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -389,7 +389,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async () let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -433,7 +433,7 @@ test("DebouncedMutation - skips commit when value unchanged", async () => { let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -493,7 +493,7 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => { let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, count: number) { helpers.setCount(count); }, @@ -546,7 +546,7 @@ test("DebouncedMutation - custom deepEquals function", async () => { testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -580,7 +580,7 @@ test("DebouncedMutation - rollback on commit error", async () => { testStore.clear(); testStore.set("counter", 10); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -613,7 +613,7 @@ test("DebouncedMutation - error event includes error details", async () => { const tracker = createEventTracker(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -649,7 +649,7 @@ test("DebouncedMutation - key() returns JSON stringified key", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic(_ctx, _id: string) {}, mode: "debounce", time: 20, @@ -670,7 +670,7 @@ test("DebouncedMutation - key() can return array", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic(_ctx, _id: string) {}, mode: "debounce", time: 20, @@ -698,7 +698,7 @@ test("DebouncedMutation - different keys create separate batches", async () => { let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, key: string, amount: number) { helpers.increment(`counter-${key}`, amount); }, @@ -736,7 +736,7 @@ test("DebouncedMutation - describe() with string", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic(_ctx, _amount: number) {}, mode: "debounce", time: 20, @@ -757,7 +757,7 @@ test("DebouncedMutation - describe() with function", () => { const { client } = createTestClient(); testStore.clear(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic(_ctx, _amount: number) {}, mode: "debounce", time: 20, @@ -783,7 +783,7 @@ test("DebouncedMutation - all pending promises resolve with same result", async testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -820,7 +820,7 @@ test("DebouncedMutation - all pending promises reject with same error", async () testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -864,7 +864,7 @@ test("DebouncedMutation - handles empty getValue result", async () => { let commitCallCount = 0; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.setValue("nonexistent", amount); }, @@ -893,7 +893,7 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async ( testStore.clear(); testStore.set("counter", 0); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -929,7 +929,7 @@ test("DebouncedMutation - default time is 200ms", async () => { let commitTime: number | null = null; const startTime = Date.now(); - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -961,7 +961,7 @@ test("DebouncedMutation - context is passed to getValue", async () => { let receivedUserId: string | undefined; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -993,7 +993,7 @@ test("DebouncedMutation - context is passed to commit", async () => { let receivedUserId: string | undefined; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, amount: number) { helpers.increment("counter", amount); }, @@ -1023,7 +1023,7 @@ test("DebouncedMutation - first args are used for commit", async () => { let receivedArgs: [string, number] | undefined; - const mutation = client.defineDebounced({ + const mutation = client.defineBatched({ optimistic({ helpers }, _label: string, amount: number) { helpers.increment("counter", amount); },