authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 16:00:05-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 16:57:22-08:00
log2d5acae7f8014bc80c4ddf58592529eeaae282af
treec4b36e20298c3cbe38a520269030468dee6d493e
parentc384cb9c7ac09948889010176f298fb775481b4b
signaturelock-open Commit is signed but in an unrecognized format.

feat: more


14 files changed, 981 insertions(+), 470 deletions(-)

example/index.html deleted-12
......@@ -1,12 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>React Mutation Demo</title>
7 </head>
8 <body>
9 <div id="root"></div>
10 <script type="module" src="./src/main.tsx"></script>
11 </body>
12</html>
example/package.json deleted-19
......@@ -1,19 +0,0 @@
1{
2 "private": true,
3 "type": "module",
4 "scripts": {
5 "dev": "vite"
6 },
7 "dependencies": {
8 "@tanstack/react-query": "^5.90.20",
9 "react": "^19.2.4",
10 "react-dom": "^19.2.4"
11 },
12 "devDependencies": {
13 "@types/node": "^24.10.1",
14 "@types/react": "^19.2.5",
15 "@types/react-dom": "^19.2.3",
16 "@vitejs/plugin-react": "^5.1.1",
17 "vite": "^7.2.4"
18 }
19}
example/src/App.tsx+2-2
......@@ -49,7 +49,7 @@ const queryCounter = queryOptions({
4949// await client.invalidateQueries(queryCounter);
5050// },
5151// });
52const mutIncrement = mutationClient.defineDebounced({
52const mutIncrement = mutationClient.defineBatched({
5353 mode: "debounce",
5454 time: 200,
5555
......@@ -71,7 +71,7 @@ const mutIncrement = mutationClient.defineDebounced({
7171 describe: "update counter",
7272 describeResult: ({ initial, current }) => {
7373 const delta = current - initial;
74 return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`;
74 return `Counter updated by ${delta > 0 ? "+" : ""}${delta}`;
7575 },
7676});
7777
example/src/index.css deleted-198
......@@ -1,198 +0,0 @@
1:root {
2 font-family: system-ui, sans-serif;
3 line-height: 1.5;
4 font-weight: 400;
5
6 color-scheme: dark;
7 color: rgba(255, 255, 255, 0.87);
8 background-color: #242424;
9
10 --primary: #646cff;
11 --primary-hover: #535bf2;
12 --success: #4ade80;
13 --error: #ef4444;
14 --warning: #f59e0b;
15 --bg: #242424;
16 --surface: #1a1a1a;
17 --text: rgba(255, 255, 255, 0.87);
18 --text-muted: rgba(255, 255, 255, 0.6);
19}
20
21body {
22 margin: 0;
23 display: flex;
24 place-items: center;
25 min-width: 320px;
26 min-height: 100vh;
27}
28
29#root {
30 width: 100%;
31}
32
33* {
34 box-sizing: border-box;
35}
36.app {
37 max-width: 800px;
38 margin: 0 auto;
39 padding: 2rem;
40 text-align: center;
41}
42
43h1 {
44 font-size: 3.2em;
45 line-height: 1.1;
46 margin-bottom: 0.5rem;
47}
48
49.subtitle {
50 color: var(--text-muted);
51 margin-bottom: 2rem;
52}
53
54.counter-card {
55 background: var(--surface);
56 border-radius: 12px;
57 padding: 2rem;
58 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
59}
60
61.counter-display {
62 margin: 2rem 0;
63}
64
65.count {
66 font-size: 4rem;
67 font-weight: bold;
68 margin: 0;
69 color: var(--primary);
70}
71
72.mutation-info {
73 margin: 2rem 0;
74 min-height: 100px;
75 text-align: left;
76}
77
78.status-badges {
79 display: flex;
80 gap: 0.5rem;
81 justify-content: center;
82 flex-wrap: wrap;
83 margin-bottom: 1rem;
84}
85
86.badge {
87 padding: 0.25rem 0.75rem;
88 border-radius: 12px;
89 font-size: 0.875rem;
90 font-weight: 500;
91}
92
93.badge.idle {
94 background: rgba(148, 163, 184, 0.2);
95 color: #94a3b8;
96}
97
98.badge.mutating {
99 background: rgba(245, 158, 11, 0.2);
100 color: var(--warning);
101}
102
103.badge.success {
104 background: rgba(74, 222, 128, 0.2);
105 color: var(--success);
106}
107
108.badge.error {
109 background: rgba(239, 68, 68, 0.2);
110 color: var(--error);
111}
112
113.badge.pending {
114 background: rgba(100, 108, 255, 0.2);
115 color: var(--primary);
116 animation: pulse 2s infinite;
117}
118
119.badge.optimistic {
120 background: rgba(168, 85, 247, 0.2);
121 color: #a855f7;
122}
123
124.success-message {
125 padding: 1rem;
126 background: rgba(74, 222, 128, 0.1);
127 border: 1px solid rgba(74, 222, 128, 0.3);
128 border-radius: 8px;
129 color: var(--success);
130 margin-top: 1rem;
131}
132
133.error-message {
134 padding: 1rem;
135 background: rgba(239, 68, 68, 0.1);
136 border: 1px solid rgba(239, 68, 68, 0.3);
137 border-radius: 8px;
138 color: var(--error);
139 margin-top: 1rem;
140}
141
142.button-group {
143 display: flex;
144 gap: 1rem;
145 justify-content: center;
146 flex-wrap: wrap;
147}
148
149button {
150 border-radius: 8px;
151 border: 1px solid transparent;
152 padding: 0.6em 1.2em;
153 font-size: 1em;
154 font-weight: 500;
155 font-family: inherit;
156 cursor: pointer;
157 transition: all 0.25s;
158}
159
160.primary-button,
161.mutation-button {
162 background-color: var(--primary);
163 color: white;
164}
165
166.primary-button:hover:not(:disabled),
167.mutation-button:hover:not(:disabled) {
168 background-color: var(--primary-hover);
169}
170
171.secondary-button {
172 background-color: transparent;
173 border-color: var(--text-muted);
174 color: var(--text);
175}
176
177.secondary-button:hover:not(:disabled) {
178 border-color: var(--primary);
179}
180
181button:disabled {
182 opacity: 0.6;
183 cursor: not-allowed;
184}
185
186button:focus,
187button:focus-visible {
188 outline: 4px auto -webkit-focus-ring-color;
189}
190
191@keyframes pulse {
192 0%, 100% {
193 opacity: 1;
194 }
195 50% {
196 opacity: 0.5;
197 }
198}
example/src/main.tsx deleted-10
......@@ -1,10 +0,0 @@
1import { StrictMode } from "react";
2import { createRoot } from "react-dom/client";
3import App from "./App.tsx";
4import "./index.css";
5
6createRoot(document.getElementById("root")!).render(
7 <StrictMode>
8 <App />
9 </StrictMode>,
10);
example/tsconfig.json deleted-25
......@@ -1,25 +0,0 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "NodeNext",
5 "lib": ["ESNext", "DOM"],
6 "rootDir": "../",
7 "strict": true,
8 "esModuleInterop": true,
9 "skipLibCheck": true,
10 "forceConsistentCasingInFileNames": true,
11 "declaration": true,
12 "declarationMap": true,
13 "sourceMap": true,
14 "moduleResolution": "nodenext",
15 "moduleDetection": "force",
16 "noEmit": true,
17 "allowImportingTsExtensions": true,
18 "jsx": "react-jsx",
19 "paths": {
20 "@clo/react-mutation": ["../src/mod.ts"]
21 }
22 },
23 "include": ["src/**/*"],
24 "exclude": ["node_modules"]
25}
example/vite.config.ts deleted-13
......@@ -1,13 +0,0 @@
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3import path from "node:path";
4
5// https://vite.dev/config/
6export default defineConfig({
7 plugins: [react()],
8 resolve: {
9 alias: {
10 "@clo/react-mutation": path.resolve(__dirname, "../src"),
11 },
12 },
13});
jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.4",
3 "version": "1.0.0-beta.5",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query.ts": "./src/tanstack-query.ts",
readme.md+194-91
......@@ -1,5 +1,7 @@
11# `@clo/react-mutation`
22
3Install via [JSR](https://jsr.io/@clo/react-mutation): `npx jsr add @clo/react-mutation`
4
35## Motivation
46
57At 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.
1012
1113The primary gains React Mutation provides are
1214
13- **Automatic error handling**. If a `useMutate` hook does not observe
15- **Automatic result handling**. If a `useMutate` hook does not observe
1416 `isError`, unhandled errors will be propagated to a global handler, which can
1517 display a UI toast. Otherwise, the component can display the error locally.
1618- Optimistic helpers allow defining rollbacks and refetching logic independant
17 of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers) show this power in more detail.
18- Debounced Mutations are just so awesome to use.
19 of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers)
20 shows this power in more detail.
21- Easy debouncing and batching utilities.
1922
2023## Usage
2124
22This library declares two kinds of mutations. Each kind has different behavior
23around concurrent operations.
24
25- [**Blocking Mutations**](#blocking-mutations): A mutation blocks the UI until it
26 is complete. You press a button, a pending state appears, then it completes.
27 This works great for forms, creations and deletions, and is similar to React
28 Query's mutation system.
29- [**Debounced Mutations**](#debounced-mutations): Each call to the mutation applies
30 new optimistic state, and after a debounce (or throttle) the new optimistic
31 state is committed to the API. UI never shows a pending state for these.
32 This works great for auto-saving input fields, follow buttons, and is
33 preferred whenever possible.
34
3525React Mutation starts with a `MutationClient`, which shares global state for an application.
3626
3727```ts
28import { QueryClient } from "@tanstack/react-query";
29import { MutationClient } from "@clo/react-mutation";
30import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation";
31import { showToastUI } from "...";
32
3833const queryClient = new QueryClient();
3934export const mutations = new MutationClient({
4035 // All properties in `context` are available within every function.
4136 context: {
4237 client: queryClient,
4338 // Can add any easy helpers for your codebase.
44 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet`)
39 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`)
4540 get: (k: QueryKey) => client.getQueryData(k),
4641 },
4742
......@@ -62,17 +57,13 @@ export const mutations = new MutationClient({
6257 reportSuccess(userFriendlySuccessMessage: string) {
6358 showToastUI("success", userFriendlyErrorMessage);
6459 },
65})
66
60});
6761```
6862
69### Blocking Mutations
63### Declaring Mutations
7064
71A blocking mutation is defined with `mutations.defineBlocking`. Example use cases:
72
73- A form to create a new resource.
74- Button operations such as deleting or resyncing.
75- Any case where it is unclear what the optimistic state should be.
65With a mutation client, you can declare mutations with `mutations.define()`.
66Start with the API call code, and then add an optimistic updater function.
7667
7768```tsx
7869const queryItemList = queryOptions({ ... });
......@@ -80,19 +71,27 @@ const queryItem = (id: string) => queryOptions({ ... });
8071
8172// The convention is to name handlers starting with `mut`
8273const mutDeleteItem = mutations.defineBlocking({
83 // `mutate` comes first, is only worried about syncing with the backend.
74 // `mutate` comes first (for type inference), and
75 // is only worried about syncing with the backend.
8476 async mutate(id: string) {
8577 const response = await fetch(`/items/${id}`, { method: "delete" });
8678 if (!response.ok) throw new Error(`HTTP ${response.status}`);
79 return response.json();
8780 },
8881
89 optimistic({ client, get, helpers, args: [id] }) {
82 // `optimistic` is provided a `helpers` object which implement automatic rollbacks.
83 optimistic({ client, get, helpers, args: [id], onSuccess }) {
9084 // Remove the matching items, but restore and refetch them on failure.
9185 helpers.arrayRemove(queryItemList, (item) => item === id);
9286 // Remove this query from the client, but restore as stale and refetch it on failure.
9387 helpers.removeQuery(queryItem);
88
89 onSuccess((result) => {
90 // in general case, you may want to apply a success update
91 });
9492 },
9593
94 // These strings are shown in error/success messages, called *after* optimistic state is applied.
9695 // Example: `Could not {description}`
9796 describe({ get, args: [id] }) {
9897 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";
......@@ -102,8 +101,8 @@ const mutDeleteItem = mutations.defineBlocking({
102101 describeResult: ({ get, args: [id] }) =>
103102 `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
104103
105 // Since the optimistic handler is perfect, there is no need
106 // to refetch any data once a success case is hit.
104 // Since the optimistic handler is perfect, there is no need to refetch any
105 // data once a success case is hit. This defaults to false for simplicity.
107106 refetchOnSuccess: false,
108107});
109108
......@@ -111,8 +110,8 @@ const mutDeleteItem = mutations.defineBlocking({
111110// indicated through UI toasts from the mutation client.
112111export function Example({ id }: { id: string }) {
113112 const { data: list } = useSuspenseQuery(queryItemList);
114 const { run } = useMutate(mutDeleteItem);
115
113 const { run, /* isPending, result, error, ... */ } = useMutate(mutDeleteItem);
114
116115 return list.map((id) => <li key={id}>
117116 <Item id={id} />
118117 <button onClick={() => run(id)}>delete</button>
......@@ -120,63 +119,6 @@ export function Example({ id }: { id: string }) {
120119}
121120```
122121
123### Debounced Mutations
124
125A debounced mutation is defined with `mutations.defineDebounced`.
126
127```tsx
128const mutSetItemName = mutationClient.defineDebounced({
129 // Think of your mutator in terms of how it applies optimistic state.
130 optimistic({ helpers }, id: string, name: string) {
131 helpers.objSet(queryItem(id), ["title"], name);
132 },
133 // A value is snapshotted *before* calling `optimistic`, and then again after
134 // the timer. If the snapshots differ, then `commit` function is called.
135 getValue: ({ get }) => get(queryCounter)?.title ?? "",
136
137 // Split different `id`s into their own debounces.
138 key: ({ args: [id] }) => id,
139
140 // Commit the result to the backend. Here, you can observe the two snapshotted
141 // values and form an API request.
142 async commit({ initial, current, args: [id] }) {
143 const response = await fetch(`/items/${id}`, {
144 method: "patch",
145 body: JSON.stringify({ title: current }),
146 });
147 if (!response.ok) throw new Error(`HTTP ${response.status}`);
148 },
149
150 describe: ({ get, args: [id] }) =>
151 `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`,
152 describeResult: ({ get, args: [id] }) =>
153 `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
154});
155
156// React example. Since the error and result are read in this hook,
157// the success and failure states will be driven through the component UI.
158function Item({ id }: { id: string }) {
159 const { data: item } = useSuspenseQuery(queryItem(id));
160 const { run, isSuccess, errorMessage } = useMutate(mutDeleteItem);
161
162 // TODO: test this pattern. maybe introduce another hook for doing good input
163 // fields that hook could also support an "Undo" button.
164 return <>
165 <input
166 value={item.title}
167 onChange={(e) => {
168 run(e.target.value);
169 }}
170 />
171 {
172 isSuccess
173 ? "Saved"
174 : errorMessage
175 ? "Error: " + errorMessage : null
176 }
177 </>
178}
179```
180122
181123### Optimistic Updates
182124
......@@ -189,7 +131,7 @@ The `optimistic` function is given an object with the following APIs
189131- `onRestore` - add a callback to revert your optimistic update
190132- `onRefetch` - add a callback to fetch data after a success
191133
192### React Query Optimistic Helpers
134#### React Query Optimistic Helpers
193135
194136When using React Query, you can opt into some incredible helpers for making it
195137very 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
235177 - `arrayRemove` - remove items by a `filter` function
236178 - `arrayUpdate` - update items by a `filter` + `update` function
237179 - `arrayInsertIndex` - insert an item at an index
238- **experimental**: Queries that are complex options. Each function takes a type-safe
239 json path to evaluate, but this system has type bugs.
180- Queries that are complex objects. Each function takes a type-safe json path to
181 evaluate, but this system currently has type bugs and is being improved.
240182 - `objSet` - set a property
241183 - `objSetMany` - set many properties at once
242184 - `objIncrement` - increment a number
......@@ -247,3 +189,164 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
247189 - `objArrayRemove` - remove items from array by `filter`
248190 - `objArrayUpdate` - update items in array by `filter` + `update`
249191 - `objArrayInsertIndex` - insert an item in an array at an index
192
193### Debouncing
194
195By default, a mutation will block the UI (by setting isPending). If you add
196`debounceMs`, the mutation will no longer set isPending. Multiple mutations
197
198```tsx
199const mutUpdateField = mutations.define({
200 async mutate(id: string, value: string) { /* mutation */ },
201 optimistic({ args: [id, value], helpers }) {
202 helpers.objSet(queryItem(id), ["value"], value);
203 },
204 // (...describe functions...)
205
206 // debounce for 0.5 seconds, grouping items on their `id`
207 debounceMs: 500,
208 key: ({ args: [id] }) => id,
209});
210
211// React example - Auto-saving text field
212function Item({ id }: { id: string }) {
213 const { data: item } = useSuspenseQuery(queryItem(id));
214 const { run, isSuccess } = useMutate();
215
216 return (
217 <input
218 value={item.value}
219 onChange={(e) => {
220 mutUpdateField.run(id, e.target.value);
221 }}
222 />
223 {}
224 );
225}
226```
227
228### Calling Mutations
229
230Three methods exist for calling mutations:
231
232- Directly on the mutation: `mutDoAction.run()`
233- From a React component: `useMutate(mutDoAction)`
234- From a React Element: `<MutationButton>`
235
236#### The `useMutate` Hook
237
238The `useMutate(null | Mutation)` react hook returns an object with the following properties.
239
240- `run` (Function) this starts the mutation.
241- `clear` (Function) clear the status of sucess or error states.
242- `isPending` (boolean) if a loading indicator should be visible.
243- `isSuccess` (boolean) if the mutation has succeeded.
244- `result` (Result or undefined) the successful result of the mutation.
245- `isError` (boolean) if the mutation failed.
246- `errorMessage` (string or undefined) a friendly error message.
247- `error` (unknown) the error value of the mutation.
248- `isMutating` (boolean) if a mutation function is currently running.
249- `isOptimisticData` (boolean) if cache data is optimistic.
250- `status`: a string enum of the mutation status.
251
252The object uses getters to determine which fields should be subscribed to for
253re-renders, but this is also used to determine how errors should be propagated.
254If the error is observed by the component, then React Mutation will know not to
255invoke the global error handler. Same for success.
256
257```ts
258const { errorMeseage, isSuccess, run: run1 } = useMutate(...); // local handling in the form
259const { run: run2 } = useMutate(...); // global handling with alerts
260
261return (
262 <>
263 <button onClick={() => run1(...)}>local</button>
264 {isSuccess ? "you win!" : errorMeseage}
265
266 <button onClick={() => run2(...)}>global</button>
267 <>
268);
269```
270
271#### Mutation Buttons
272
273You can wrap your button component with `createMutationButton` to make it support mutations
274
275```tsx
276function MutationButtonBase({
277 isPending,
278 disabled,
279 children,
280 ...args
281}: {
282 isPending: boolean;
283 iconButton?: boolean;
284} & ButtonProps) {
285 return (
286 <Button {...args} disabled={disabled || isPending}>
287 <div className="flex items-center gap-2">
288 {isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
289 {(!args.iconButton || !isPending) && children}
290 </div>
291 </Button>
292 );
293}
294export const MutationButton = createMutationButton(MutationButtonBase);
295```
296
297It can now be used for easy mutations:
298
299```tsx
300<>
301 {/* Static Arguments */}
302 <MutationButton mutation={mutToggleFollow} args={[ userId ]}>Follow</MutationButton>
303
304 {/* Dynamic Arguments */}
305 <MutationButton mutation={mutSendMessage} args={(e) => {
306 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
307 return [userId, messageContent];
308 }}>Send Message</MutationButton>
309</>
310```
311
312
313
314### Batched Mutations
315
316Each call to the mutation applies new optimistic state on top of the previous,
317and after a debounce / throttle, the new optimistic state is committed to the
318API. UI never shows a pending state for these. This works great for toggle buttons
319and any other state where you'd like to define an optimistic state
320
321In many places, similar behavior can be achieved with standard mutations and its
322`debounceMs` field.
323
324```tsx
325const mutToggleFollow = mutations.defineBatched({
326 // Think of your mutator in terms of how it applies optimistic state.
327 optimistic({ helpers }, userId: string) {
328 helpers.objToggle(queryUser(userId), ["following"]);
329 },
330 // A value is snapshotted *before* calling `optimistic`, and then again after
331 // the timer. If the snapshots differ, then `commit` function is called.
332 getValue: ({ get }) => get(queryUser(id))?.following,
333
334 // Split different `id`s into their own batches.
335 key: ({ args: [id] }) => id,
336
337 // Commit the result to the backend.
338 // Here, you can observe the two snapshotted values and form an API request.
339 async commit({ initial, current, args: [id] }) {
340 const response = await fetch(`/items/${id}`, {
341 method: "patch",
342 body: JSON.stringify({ title: current }),
343 });
344 if (!response.ok) throw new Error(`HTTP ${response.status}`);
345 },
346
347 describe: ({ get, args: [id] }) =>
348 `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`,
349 describeResult: ({ get, args: [id] }) =>
350 `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
351});
352```
src/blocking.ts+260-22
......@@ -9,7 +9,7 @@ import { message as errMessage } from "@clo/lib/error.ts";
99 * @template Result - the result of the API call
1010 * @template Config - global values and helpers from `MutationContext`
1111 */
12export interface BlockingMutationOptions<
12export interface MutationOptions<
1313 Args extends unknown[],
1414 Result,
1515 Config extends MutationClientConfig,
......@@ -34,12 +34,17 @@ export interface BlockingMutationOptions<
3434 * Used in success messages.
3535 * Phrase it as a complete success message, e.g., "Deleted item successfully"
3636 */
37 describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null;
37 describeResult:
38 | string
39 | ((context: Config["context"] & { args: Args; result: Result }) => string)
40 | null;
3841 /**
3942 * Specifying the optimistic strategy is required. To disable, pass an empty
4043 * function with a comment to document why it isn't needed.
4144 */
42 optimistic: (context: BlockingOptimisticContext<Args, Result, Config>) => void;
45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;
4348 /**
4449 * Refetch all of the data this mutation could have affected.
4550 * Normally, optimistic helpers will perform
......@@ -57,9 +62,16 @@ export interface BlockingMutationOptions<
5762 * but optimistic updates will apply instantly.
5863 */
5964 key?: (context: Config["context"] & { args: Args }) => string | string[];
65 /**
66 * Enable debouncing with "last call wins" behavior. When rapid calls arrive,
67 * the previous optimistic update is rolled back and the new one applied.
68 *
69 * All pending promises resolve with the final result.
70 */
71 debounceMs?: number;
6072}
6173
62export type BlockingOptimisticContext<
74export type OptimisticContext<
6375 Args extends unknown[],
6476 Result,
6577 Config extends MutationClientConfig,
......@@ -74,14 +86,31 @@ export type BlockingOptimisticContext<
7486 onRefetch: (cb: () => Promise<void>) => void;
7587};
7688
77interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> {
89interface PendingDebouncedState<Args extends unknown[], Result> {
90 /** Arguments from the most recent call */
91 args: Args;
92 /** Number of rollbacks the most recent call added */
93 rollbackCount: number;
94 /** All pending promises from all superseded calls */
95 pending: Array<{
96 resolve: (result: Result) => void;
97 reject: (error: unknown) => void;
98 }>;
99 /** Success callbacks from the most recent call */
100 onSuccess: Array<(result: Result) => void>;
101}
102
103interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
78104 listeners: Set<(update: MutationEvent<Result>) => void>;
79 status: "idle" | "mutating" | "refetching";
105 status: "idle" | "waiting" | "mutating" | "refetching";
80106 rollbacks: Array<() => void>;
81107 refetches: Array<() => Promise<void>>;
82108 queue: Array<Item<Args, Result>>;
83109 // Shared optimistic helpers instance for the channel
84110 helpers: OptimisticHelpers | null;
111 // Debounce state (only used if debounce option is set)
112 debounceTimer: ReturnType<typeof setTimeout> | null;
113 pendingDebounced: PendingDebouncedState<Args, Result> | null;
85114}
86115
87116interface Item<Args extends unknown[], Result> {
......@@ -97,14 +126,17 @@ export class BlockingMutation<
97126 Result,
98127 Config extends MutationClientConfig,
99128> implements Mutation<Args, Result> {
100 #options: BlockingMutationOptions<Args, Result, Config>;
129 #options: MutationOptions<Args, Result, Config>;
101130 #client: MutationClientFromConfig<Config>;
102 #channels: Map<string, BlockingChannel<Args, Result, Config["optimisticHelpers"]>> = new Map();
131 #channels: Map<
132 string,
133 Channel<Args, Result, Config["optimisticHelpers"]>
134 > = new Map();
103135 client: MutationClientFromConfig<Config>;
104136
105137 constructor(
106138 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
107 options: BlockingMutationOptions<Args, Result, Config>,
139 options: MutationOptions<Args, Result, Config>,
108140 ) {
109141 this.#options = options;
110142 this.#client = client;
......@@ -128,6 +160,8 @@ export class BlockingMutation<
128160 refetches: [],
129161 queue: [],
130162 helpers: null,
163 debounceTimer: null,
164 pendingDebounced: null,
131165 };
132166 this.#channels.set(key, channel);
133167 }
......@@ -144,7 +178,7 @@ export class BlockingMutation<
144178 }
145179
146180 #notify(
147 channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>,
181 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
148182 status: MutationEvent<Result>["status"],
149183 result: Result | null = null,
150184 error: unknown = null,
......@@ -153,14 +187,30 @@ export class BlockingMutation<
153187 channel.listeners.forEach((cb) => cb(event));
154188 }
155189
156 #setIdle(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {
157 channel.status = "idle";
158 // Discard any unconsumed refetch callbacks
159 channel.refetches = [];
160 this.#notify(channel, "idle", null, null);
161 // Clean up the channel if there are no listeners
162 if (channel.listeners.size === 0) {
163 this.#channels.delete(key);
190 #setIdle(
191 key: string,
192 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
193 ) {
194 // Check if there are pending debounced calls waiting
195 if (channel.pendingDebounced !== null) {
196 // Stay in waiting state
197 channel.status = "waiting";
198 this.#notify(channel, "waiting", null, null);
199 } else {
200 // Normal idle transition
201 channel.status = "idle";
202 // Discard any unconsumed refetch callbacks
203 channel.refetches = [];
204 this.#notify(channel, "idle", null, null);
205 // Clean up the channel if there are no listeners
206 if (channel.listeners.size === 0) {
207 // Clear any pending timers before deleting the channel
208 if (channel.debounceTimer !== null) {
209 clearTimeout(channel.debounceTimer);
210 channel.debounceTimer = null;
211 }
212 this.#channels.delete(key);
213 }
164214 }
165215 }
166216
......@@ -192,7 +242,9 @@ export class BlockingMutation<
192242 this.#client.reportSuccess(message);
193243 }
194244 }).catch((error) => {
195 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
245 const message = `Failed to ${this.describe(...args)}: ${
246 errMessage(error)
247 }`;
196248 this.#client.reportError(message, error);
197249 });
198250 }
......@@ -207,6 +259,11 @@ export class BlockingMutation<
207259 const key = this.key(args);
208260 const channel = this.#getOrPutChannel(key);
209261
262 // Check if debouncing is enabled
263 if (this.#options.debounceMs !== undefined) {
264 return this.#runDebouncedAndReturn(args, key, channel);
265 }
266
210267 // Create shared optimistic helpers instance for the channel if it doesn't exist
211268 if (channel.helpers === null) {
212269 const onRefetch = (cb: () => Promise<void>) => {
......@@ -285,7 +342,10 @@ export class BlockingMutation<
285342 return promise;
286343 }
287344
288 #executeNext(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {
345 #executeNext(
346 key: string,
347 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
348 ) {
289349 const item = channel.queue.shift();
290350 if (!item) {
291351 this.#setIdle(key, channel);
......@@ -316,7 +376,9 @@ export class BlockingMutation<
316376 // Report any errors from refetch or callbacks
317377 results.forEach((result) => {
318378 if (result.status === "rejected") {
319 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
379 const message = `Failed to refetch after ${
380 this.describe(...args)
381 }: ${errMessage(result.reason)}`;
320382 this.#client.reportError(message, result.reason);
321383 }
322384 });
......@@ -358,7 +420,9 @@ export class BlockingMutation<
358420 // Report any errors from refetch or callbacks
359421 results.forEach((result) => {
360422 if (result.status === "rejected") {
361 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
423 const message = `Failed to refetch after ${
424 this.describe(...args)
425 }: ${errMessage(result.reason)}`;
362426 this.#client.reportError(message, result.reason);
363427 }
364428 });
......@@ -369,4 +433,178 @@ export class BlockingMutation<
369433 reject(error);
370434 });
371435 }
436
437 #runDebouncedAndReturn(
438 args: Args,
439 key: string,
440 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
441 ): Promise<Result> {
442 // If there's a pending debounced call, roll it back
443 if (channel.pendingDebounced) {
444 this.#rollbackPendingDebounced(channel);
445 }
446
447 // Create shared helpers if needed (same as current implementation)
448 if (channel.helpers === null) {
449 const onRefetch = (cb: () => Promise<void>) => {
450 channel.refetches.push(cb);
451 };
452 channel.helpers = this.#client.getOptimisticHelpers({
453 onRestore: (cb: () => void) => {
454 channel.rollbacks.push(cb);
455 },
456 onRefetch,
457 });
458 }
459
460 // Apply optimistic update (same logic as current runAndReturn)
461 const onSuccess: Array<(result: Result) => void> = [];
462 let expired = false;
463 let rollbacks = 0;
464 const onRestore = (cb: () => void) => {
465 if (expired) {
466 throw new Error(
467 "Can only call onRestore from within the optimistic update function.",
468 );
469 }
470 channel.rollbacks.push(cb);
471 rollbacks += 1;
472 };
473
474 try {
475 this.#options.optimistic({
476 args,
477 helpers: channel.helpers,
478 onRestore,
479 onSuccess(cb) {
480 if (expired) {
481 throw new Error(
482 "Can only call onSuccess from within the optimistic update function.",
483 );
484 }
485 onSuccess.push(cb);
486 },
487 onRefetch(cb) {
488 if (expired) {
489 throw new Error(
490 "Can only call onRefetch from within the optimistic update function.",
491 );
492 }
493 channel.refetches.push(cb);
494 },
495 });
496 } catch (error) {
497 expired = true;
498 // Roll back the rollbacks we just added
499 let next;
500 while (
501 next =
502 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
503 ) {
504 next();
505 }
506 return Promise.reject(error);
507 }
508 expired = true;
509
510 // Create promise for this call
511 const { promise, resolve, reject } = Promise.withResolvers<Result>();
512
513 // Store or update pending debounced state
514 if (channel.pendingDebounced === null) {
515 // First debounced call
516 channel.pendingDebounced = {
517 args,
518 rollbackCount: rollbacks,
519 pending: [{ resolve, reject }],
520 onSuccess,
521 };
522
523 // Set status to waiting
524 channel.status = "waiting";
525 this.#notify(channel, "waiting");
526 } else {
527 // Subsequent debounced call - update state
528 channel.pendingDebounced.args = args;
529 channel.pendingDebounced.rollbackCount = rollbacks;
530 channel.pendingDebounced.pending.push({ resolve, reject });
531 channel.pendingDebounced.onSuccess = onSuccess;
532 // Status stays "waiting"
533 }
534
535 // Clear existing timer
536 if (channel.debounceTimer !== null) {
537 clearTimeout(channel.debounceTimer);
538 }
539
540 // Start new timer
541 channel.debounceTimer = setTimeout(() => {
542 this.#enqueueDebouncedCall(key, channel);
543 }, this.#options.debounceMs);
544
545 return promise;
546 }
547
548 #rollbackPendingDebounced(
549 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
550 ) {
551 if (!channel.pendingDebounced) return;
552
553 const { rollbackCount } = channel.pendingDebounced;
554
555 // Roll back this call's optimistic updates (in reverse order)
556 // Remove from the end of the rollbacks array
557 for (let i = 0; i < rollbackCount; i++) {
558 const rollback = channel.rollbacks.pop();
559 if (rollback) rollback();
560 }
561
562 // Note: We do NOT reject the promises here
563 // They will all resolve when the final call completes
564 }
565
566 #enqueueDebouncedCall(
567 key: string,
568 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
569 ) {
570 // Clear timer
571 channel.debounceTimer = null;
572
573 // Safety check
574 if (!channel.pendingDebounced) {
575 this.#setIdle(key, channel);
576 return;
577 }
578
579 const { args, rollbackCount, pending, onSuccess } =
580 channel.pendingDebounced;
581 channel.pendingDebounced = null;
582
583 // Create wrapper resolve/reject that resolves ALL pending promises
584 const {
585 promise: wrapperPromise,
586 resolve: wrapperResolve,
587 reject: wrapperReject,
588 } = Promise.withResolvers<Result>();
589
590 wrapperPromise.then(
591 (result) => pending.forEach((p) => p.resolve(result)),
592 (error) => pending.forEach((p) => p.reject(error)),
593 );
594
595 // Add to queue (same structure as regular blocking mutation)
596 channel.queue.push({
597 args,
598 rollbacks: rollbackCount,
599 onSuccess,
600 resolve: wrapperResolve,
601 reject: wrapperReject,
602 });
603
604 // If queue was idle/waiting, start execution
605 if (channel.status === "idle" || channel.status === "waiting") {
606 this.#executeNext(key, channel);
607 }
608 // Otherwise, it will execute when the current item finishes
609 }
372610}
src/client.ts+10-9
......@@ -1,5 +1,8 @@
1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
2import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts";
1import {
2 DebouncedMutation,
3 type DebouncedMutationOptions,
4} from "./debounced.ts";
5import { BlockingMutation, type MutationOptions } from "./blocking.ts";
36import type { Mutation } from "./types.ts";
47
58export interface MutationClientConfig {
......@@ -63,12 +66,10 @@ export class MutationClient<
6366 }
6467
6568 /**
66 * Define a blocking mutation. A mutation blocks the UI until it is complete.
67 * You press a button, a pending state appears, then it completes. This works
68 * great for forms, and is similar to React Query's mutation system.
69 * Define a standard mutation.
6970 */
70 defineBlocking<const Args extends unknown[], Result>(
71 options: BlockingMutationOptions<
71 define<const Args extends unknown[], Result>(
72 options: MutationOptions<
7273 Args,
7374 Result,
7475 { context: Context; optimisticHelpers: OptimisticHelpers }
......@@ -82,13 +83,13 @@ export class MutationClient<
8283 }
8384
8485 /**
85 * Define a debounced mutation. Each call to the mutation applies new optimistic
86 * Define a batched mutation. Each call to the mutation applies new optimistic
8687 * state, and after a debounce or throttle, the new optimistic state is
8788 * committed to the API. UI never shows a pending state for debounced mutations. This
8889 * works great for auto-saving input fields, follow buttons, and is preferred
8990 * whenever possible.
9091 */
91 defineDebounced<const Args extends unknown[], Result, Optimistic>(
92 defineBatched<const Args extends unknown[], Result, Optimistic>(
9293 options: DebouncedMutationOptions<
9394 Args,
9495 Result,
src/mod.ts+1-4
......@@ -1,7 +1,4 @@
1export type {
2 BlockingMutationOptions,
3 BlockingOptimisticContext,
4} from "./blocking.ts";
1export type { MutationOptions, OptimisticContext } from "./blocking.ts";
52export type {
63 DebouncedCommitContext,
74 DebouncedMutationOptions,
test/blocking.test.ts+486-37
......@@ -49,7 +49,7 @@ test("BlockingMutation - basic mutation success", async () => {
4949 let mutateCallCount = 0;
5050 let refetchCallCount = 0;
5151
52 const mutation = client.defineBlocking({
52 const mutation = client.define({
5353 async mutate(value: string) {
5454 mutateCallCount++;
5555 await delay(10);
......@@ -78,7 +78,7 @@ test("BlockingMutation - basic mutation success", async () => {
7878test("BlockingMutation - run() catches errors", async () => {
7979 const { client, errors } = createTestClient();
8080
81 const mutation = client.defineBlocking({
81 const mutation = client.define({
8282 async mutate(_value: string) {
8383 throw new Error("mutation failed");
8484 },
......@@ -98,7 +98,7 @@ test("BlockingMutation - run() catches errors", async () => {
9898test("BlockingMutation - runAndReturn() rejects on error", async () => {
9999 const { client } = createTestClient();
100100
101 const mutation = client.defineBlocking({
101 const mutation = client.define({
102102 async mutate(_value: string) {
103103 throw new Error("mutation failed");
104104 },
......@@ -119,7 +119,7 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =
119119 const { client } = createTestClient();
120120 testStore.clear();
121121
122 const mutation = client.defineBlocking({
122 const mutation = client.define({
123123 async mutate(_key: string, value: string) {
124124 await delay(50);
125125 return value;
......@@ -147,7 +147,7 @@ test("BlockingMutation - rollback on error", async () => {
147147 const { client } = createTestClient();
148148 testStore.clear();
149149
150 const mutation = client.defineBlocking({
150 const mutation = client.define({
151151 async mutate(_key: string, _value: string) {
152152 await delay(10);
153153 throw new Error("mutation failed");
......@@ -171,7 +171,7 @@ test("BlockingMutation - onSuccess callback is called", async () => {
171171 const { client } = createTestClient();
172172 const successResults: string[] = [];
173173
174 const mutation = client.defineBlocking({
174 const mutation = client.define({
175175 async mutate(value: string) {
176176 return `result-${value}`;
177177 },
......@@ -194,7 +194,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
194194 const { client } = createTestClient();
195195 const executionOrder: string[] = [];
196196
197 const mutation = client.defineBlocking({
197 const mutation = client.define({
198198 async mutate(id: string) {
199199 executionOrder.push(`start-${id}`);
200200 await delay(20);
......@@ -226,7 +226,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
226226 const { client } = createTestClient();
227227 const executionOrder: string[] = [];
228228
229 const mutation = client.defineBlocking({
229 const mutation = client.define({
230230 async mutate(id: string) {
231231 executionOrder.push(`start-${id}`);
232232 await delay(20);
......@@ -256,7 +256,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
256256test("BlockingMutation - key() returns JSON stringified key", () => {
257257 const { client } = createTestClient();
258258
259 const mutation = client.defineBlocking({
259 const mutation = client.define({
260260 async mutate(id: string) {
261261 return id;
262262 },
......@@ -276,7 +276,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {
276276test("BlockingMutation - key() defaults to 'shared' when no key function", () => {
277277 const { client } = createTestClient();
278278
279 const mutation = client.defineBlocking({
279 const mutation = client.define({
280280 async mutate(id: string) {
281281 return id;
282282 },
......@@ -292,7 +292,7 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>
292292test("BlockingMutation - key() can return array", () => {
293293 const { client } = createTestClient();
294294
295 const mutation = client.defineBlocking({
295 const mutation = client.define({
296296 async mutate(_userId: string, _itemId: string) {
297297 return "result";
298298 },
......@@ -315,7 +315,7 @@ test("BlockingMutation - key() can return array", () => {
315315test("BlockingMutation - describe() with string", () => {
316316 const { client } = createTestClient();
317317
318 const mutation = client.defineBlocking({
318 const mutation = client.define({
319319 async mutate(value: string) {
320320 return value;
321321 },
......@@ -331,7 +331,7 @@ test("BlockingMutation - describe() with string", () => {
331331test("BlockingMutation - describe() with function", () => {
332332 const { client } = createTestClient();
333333
334 const mutation = client.defineBlocking({
334 const mutation = client.define({
335335 async mutate(id: string) {
336336 return id;
337337 },
......@@ -349,7 +349,7 @@ test("BlockingMutation - describe() with function", () => {
349349test("BlockingMutation - describe() receives context", () => {
350350 const { client } = createTestClient();
351351
352 const mutation = client.defineBlocking({
352 const mutation = client.define({
353353 async mutate(id: string) {
354354 return id;
355355 },
......@@ -371,7 +371,7 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {
371371 const { client } = createTestClient();
372372 const tracker = createEventTracker<string>();
373373
374 const mutation = client.defineBlocking({
374 const mutation = client.define({
375375 async mutate(value: string) {
376376 await delay(10);
377377 return `result-${value}`;
......@@ -401,7 +401,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
401401 const { client } = createTestClient();
402402 const tracker = createEventTracker<string>();
403403
404 const mutation = client.defineBlocking({
404 const mutation = client.define({
405405 async mutate(value: string) {
406406 await delay(10);
407407 return value;
......@@ -429,7 +429,7 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
429429 const { client } = createTestClient();
430430 let refetchCallCount = 0;
431431
432 const mutation = client.defineBlocking({
432 const mutation = client.define({
433433 async mutate(_value: string) {
434434 return _value;
435435 },
......@@ -451,7 +451,7 @@ test("BlockingMutation - refetch is called on error", async () => {
451451 const { client } = createTestClient();
452452 let refetchCallCount = 0;
453453
454 const mutation = client.defineBlocking({
454 const mutation = client.define({
455455 async mutate(_value: string) {
456456 throw new Error("mutation failed");
457457 },
......@@ -472,7 +472,7 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {
472472 const { client } = createTestClient();
473473 const executionOrder: string[] = [];
474474
475 const mutation = client.defineBlocking({
475 const mutation = client.define({
476476 async mutate(id: string) {
477477 executionOrder.push(`start-${id}`);
478478 await delay(10);
......@@ -507,7 +507,7 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async
507507 const { client } = createTestClient();
508508 const rollbackOrder: number[] = [];
509509
510 const mutation = client.defineBlocking({
510 const mutation = client.define({
511511 async mutate(_value: string) {
512512 throw new Error("mutation failed");
513513 },
......@@ -531,7 +531,7 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati
531531 const { client } = createTestClient();
532532 const rollbackOrder: string[] = [];
533533
534 const mutation = client.defineBlocking({
534 const mutation = client.define({
535535 async mutate(id: string) {
536536 await delay(10);
537537 if (id === "fail") {
......@@ -565,7 +565,7 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase
565565 const { client } = createTestClient();
566566 let capturedOnRestore: ((cb: () => void) => void) | null = null;
567567
568 const mutation = client.defineBlocking({
568 const mutation = client.define({
569569 async mutate(_value: string) {
570570 return "result";
571571 },
......@@ -597,7 +597,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
597597 const { client } = createTestClient();
598598 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
599599
600 const mutation = client.defineBlocking({
600 const mutation = client.define({
601601 async mutate(_value: string) {
602602 return "result";
603603 },
......@@ -628,7 +628,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
628628test("BlockingMutation - error during optimistic update is rejected immediately", async () => {
629629 const { client } = createTestClient();
630630
631 const mutation = client.defineBlocking({
631 const mutation = client.define({
632632 async mutate(_value: string) {
633633 return "result";
634634 },
......@@ -651,7 +651,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
651651 const { client } = createTestClient();
652652 const rollbackOrder: number[] = [];
653653
654 const mutation = client.defineBlocking({
654 const mutation = client.define({
655655 async mutate(_value: string) {
656656 return "result";
657657 },
......@@ -675,7 +675,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
675675test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => {
676676 const { client, errors } = createTestClient();
677677
678 const mutation = client.defineBlocking({
678 const mutation = client.define({
679679 async mutate(value: string) {
680680 return value;
681681 },
......@@ -702,7 +702,7 @@ test("BlockingMutation - optimistic function receives args and helpers", async (
702702 let receivedArgs: unknown[] | undefined;
703703 let receivedHelpers: unknown | undefined;
704704
705 const mutation = client.defineBlocking({
705 const mutation = client.define({
706706 async mutate(_value: string) {
707707 return "result";
708708 },
......@@ -726,7 +726,7 @@ test("BlockingMutation - refetch receives context and args", async () => {
726726 let receivedUserId: string | undefined;
727727 let receivedArgs: unknown[] | undefined;
728728
729 const mutation = client.defineBlocking({
729 const mutation = client.define({
730730 async mutate(_id: string, value: string) {
731731 return value;
732732 },
......@@ -749,7 +749,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
749749 const { client } = createTestClient();
750750 const tracker = createEventTracker<string>();
751751
752 const mutation = client.defineBlocking({
752 const mutation = client.define({
753753 async mutate(_value: string) {
754754 await delay(10);
755755 throw new Error("mutation failed");
......@@ -778,7 +778,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
778778 const tracker1 = createEventTracker<string>();
779779 const tracker2 = createEventTracker<string>();
780780
781 const mutation = client.defineBlocking({
781 const mutation = client.define({
782782 async mutate(value: string) {
783783 await delay(5);
784784 return value;
......@@ -806,7 +806,7 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()
806806 const { client } = createTestClient();
807807 const callOrder: string[] = [];
808808
809 const mutation = client.defineBlocking({
809 const mutation = client.define({
810810 async mutate(value: string) {
811811 return value;
812812 },
......@@ -837,7 +837,7 @@ test("BlockingMutation - result is passed to notification on success", async ()
837837 const { client } = createTestClient();
838838 const tracker = createEventTracker<string>();
839839
840 const mutation = client.defineBlocking({
840 const mutation = client.define({
841841 async mutate(value: string) {
842842 await delay(5);
843843 return `result-${value}`;
......@@ -868,7 +868,7 @@ test("BlockingMutation - channel is reused for same key", async () => {
868868 const { client } = createTestClient();
869869 const events: string[] = [];
870870
871 const mutation = client.defineBlocking({
871 const mutation = client.define({
872872 async mutate(value: string) {
873873 events.push(`mutate-${value}`);
874874 return value;
......@@ -894,7 +894,7 @@ test("BlockingMutation - channel is reused for same key", async () => {
894894test("BlockingMutation - empty queue after all mutations complete", async () => {
895895 const { client } = createTestClient();
896896
897 const mutation = client.defineBlocking({
897 const mutation = client.define({
898898 async mutate(value: string) {
899899 await delay(5);
900900 return value;
......@@ -929,7 +929,7 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()
929929 const { client } = createTestClient();
930930 const results: string[] = [];
931931
932 const mutation = client.defineBlocking({
932 const mutation = client.define({
933933 async mutate(value: string) {
934934 return value;
935935 },
......@@ -953,7 +953,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
953953 const { client } = createTestClient();
954954 let refetchCalled = false;
955955
956 const mutation = client.defineBlocking({
956 const mutation = client.define({
957957 async mutate(value: string) {
958958 return value;
959959 },
......@@ -976,7 +976,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
976976test("BlockingMutation - refetch error after mutation failure is reported", async () => {
977977 const { client, errors } = createTestClient();
978978
979 const mutation = client.defineBlocking({
979 const mutation = client.define({
980980 async mutate(_value: string) {
981981 throw new Error("mutation failed");
982982 },
......@@ -1004,3 +1004,452 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn
10041004 "refetch also failed",
10051005 );
10061006});
1007
1008// ============================================================================
1009// Debouncing Tests
1010// ============================================================================
1011
1012test("BlockingMutation - debounce: basic debounced execution", async () => {
1013 const { client } = createTestClient();
1014 testStore.clear();
1015 let mutateCallCount = 0;
1016
1017 const mutation = client.define({
1018 async mutate(key: string, value: string) {
1019 mutateCallCount++;
1020 await delay(10);
1021 return `result-${value}`;
1022 },
1023 describe: "debounced mutation",
1024 describeResult: "Success",
1025 optimistic({ args, helpers }) {
1026 const [key, value] = args;
1027 helpers.setValue(key, value);
1028 },
1029 async refetch() {},
1030 debounceMs: 50,
1031 });
1032
1033 const promise = mutation.runAndReturn("key1", "value1");
1034
1035 // Optimistic update should be applied immediately
1036 assertEquals(testStore.get("key1"), "value1");
1037
1038 // Mutation should not have executed yet
1039 assertEquals(mutateCallCount, 0);
1040
1041 // Wait for debounce to complete
1042 const result = await promise;
1043 assertEquals(result, "result-value1");
1044 assertEquals(mutateCallCount, 1);
1045});
1046
1047test("BlockingMutation - debounce: last call wins with multiple rapid calls", async () => {
1048 const { client } = createTestClient();
1049 testStore.clear();
1050 let mutateCallCount = 0;
1051 const mutateArgs: Array<[string, string]> = [];
1052
1053 const mutation = client.define({
1054 async mutate(key: string, value: string) {
1055 mutateCallCount++;
1056 mutateArgs.push([key, value]);
1057 await delay(10);
1058 return `result-${value}`;
1059 },
1060 describe: "debounced mutation",
1061 describeResult: "Success",
1062 optimistic({ args, helpers }) {
1063 const [key, value] = args;
1064 helpers.setValue(key, value);
1065 },
1066 async refetch() {},
1067 debounceMs: 50,
1068 });
1069
1070 // Make three rapid calls
1071 const promise1 = mutation.runAndReturn("key1", "a");
1072 const promise2 = mutation.runAndReturn("key1", "b");
1073 const promise3 = mutation.runAndReturn("key1", "c");
1074
1075 // Last optimistic update should be applied
1076 assertEquals(testStore.get("key1"), "c");
1077
1078 // Wait for debounce to complete
1079 const [result1, result2, result3] = await Promise.all([
1080 promise1,
1081 promise2,
1082 promise3,
1083 ]);
1084
1085 // All promises should resolve with the same result
1086 assertEquals(result1, "result-c");
1087 assertEquals(result2, "result-c");
1088 assertEquals(result3, "result-c");
1089
1090 // Only one mutation should have executed, with the last args
1091 assertEquals(mutateCallCount, 1);
1092 assertEquals(mutateArgs, [["key1", "c"]]);
1093});
1094
1095test("BlockingMutation - debounce: optimistic rollback and reapply", async () => {
1096 const { client } = createTestClient();
1097 testStore.clear();
1098
1099 const mutation = client.define({
1100 async mutate(key: string, value: string) {
1101 await delay(10);
1102 return `result-${value}`;
1103 },
1104 describe: "debounced mutation",
1105 describeResult: "Success",
1106 optimistic({ args, helpers }) {
1107 const [key, value] = args;
1108 helpers.setValue(key, value);
1109 // Add a second value to test multiple rollbacks
1110 helpers.setValue(`${key}-2`, `${value}-2`);
1111 },
1112 async refetch() {},
1113 debounceMs: 50,
1114 });
1115
1116 // First call sets two values
1117 mutation.runAndReturn("key1", "a");
1118 assertEquals(testStore.get("key1"), "a");
1119 assertEquals(testStore.get("key1-2"), "a-2");
1120
1121 // Second call should rollback first call's optimistic and apply its own
1122 const promise = mutation.runAndReturn("key1", "b");
1123 assertEquals(testStore.get("key1"), "b");
1124 assertEquals(testStore.get("key1-2"), "b-2");
1125
1126 // Wait for completion
1127 await promise;
1128 await delay(20);
1129
1130 // Final values should still be from the last call
1131 assertEquals(testStore.get("key1"), "b");
1132 assertEquals(testStore.get("key1-2"), "b-2");
1133});
1134
1135test("BlockingMutation - debounce: timer reset behavior", async () => {
1136 const { client } = createTestClient();
1137 let mutateCallCount = 0;
1138
1139 const mutation = client.define({
1140 async mutate(value: string) {
1141 mutateCallCount++;
1142 return `result-${value}`;
1143 },
1144 describe: "debounced mutation",
1145 describeResult: "Success",
1146 optimistic() {},
1147 async refetch() {},
1148 debounceMs: 100,
1149 });
1150
1151 // Call at t=0
1152 const promise1 = mutation.runAndReturn("first");
1153
1154 // Call at t=50 (should reset timer)
1155 await delay(50);
1156 const promise2 = mutation.runAndReturn("second");
1157
1158 // At t=100, mutation should NOT have executed yet
1159 await delay(50);
1160 assertEquals(mutateCallCount, 0);
1161
1162 // At t=150, mutation should execute
1163 await delay(50);
1164 await Promise.all([promise1, promise2]);
1165
1166 assertEquals(mutateCallCount, 1);
1167});
1168
1169test("BlockingMutation - debounce: integration with blocking queue", async () => {
1170 const { client } = createTestClient();
1171 const executionOrder: string[] = [];
1172
1173 const mutation = client.define({
1174 async mutate(id: string) {
1175 executionOrder.push(`start-${id}`);
1176 await delay(30);
1177 executionOrder.push(`end-${id}`);
1178 return `result-${id}`;
1179 },
1180 describe: "debounced mutation",
1181 describeResult: "Success",
1182 optimistic() {},
1183 async refetch() {},
1184 debounceMs: 30,
1185 key: () => "shared",
1186 });
1187
1188 // Start a debounced call that will enter queue first
1189 const promise1 = mutation.runAndReturn("first");
1190
1191 // While it's waiting in debounce, fire more debounced calls
1192 await delay(10);
1193 const promise2 = mutation.runAndReturn("second");
1194 const promise3 = mutation.runAndReturn("third");
1195
1196 // Wait for all to complete
1197 await Promise.all([promise1, promise2, promise3]);
1198
1199 // Only third should execute (last call wins)
1200 assertEquals(executionOrder, [
1201 "start-third",
1202 "end-third",
1203 ]);
1204});
1205
1206test("BlockingMutation - debounce: error during optimistic update", async () => {
1207 const { client } = createTestClient();
1208 testStore.clear();
1209
1210 const mutation = client.define({
1211 async mutate(_value: string) {
1212 return "result";
1213 },
1214 describe: "debounced mutation",
1215 describeResult: "Success",
1216 optimistic({ args, helpers }) {
1217 const [value] = args;
1218 if (value === "error") {
1219 throw new Error("optimistic error");
1220 }
1221 helpers.setValue("key", value);
1222 },
1223 async refetch() {},
1224 debounceMs: 50,
1225 });
1226
1227 // Call that throws during optimistic
1228 await assertRejects(
1229 () => mutation.runAndReturn("error"),
1230 Error,
1231 "optimistic error",
1232 );
1233
1234 // Store should be empty
1235 assertEquals(testStore.has("key"), false);
1236
1237 // Subsequent successful call should work
1238 const promise = mutation.runAndReturn("good");
1239 assertEquals(testStore.get("key"), "good");
1240 await promise;
1241});
1242
1243test("BlockingMutation - debounce: status transitions", async () => {
1244 const { client } = createTestClient();
1245 const { events, callback } = createEventTracker();
1246
1247 const mutation = client.define({
1248 async mutate(value: string) {
1249 await delay(20);
1250 return `result-${value}`;
1251 },
1252 describe: "debounced mutation",
1253 describeResult: "Success",
1254 optimistic() {},
1255 async refetch() {
1256 await delay(10);
1257 },
1258 debounceMs: 50,
1259 });
1260
1261 const key = mutation.key(["test"]);
1262 const unsubscribe = mutation.subscribe(key, callback);
1263
1264 // First call should transition to waiting
1265 mutation.runAndReturn("test");
1266 await delay(10);
1267 assertEquals(events[events.length - 1].status, "waiting");
1268
1269 // Wait for debounce and mutation to complete
1270 await delay(80);
1271
1272 // Should have transitioned: waiting -> mutating -> refetching -> idle
1273 const statuses = events.map((e) => e.status);
1274 assertEquals(statuses, ["waiting", "mutating", "refetching", "idle"]);
1275
1276 unsubscribe();
1277});
1278
1279test("BlockingMutation - debounce: debounced call executes after queue error", async () => {
1280 const { client } = createTestClient();
1281 let callCount = 0;
1282
1283 const mutation = client.define({
1284 async mutate(id: string) {
1285 callCount++;
1286 if (id === "fail") {
1287 throw new Error("mutation failed");
1288 }
1289 await delay(20);
1290 return `result-${id}`;
1291 },
1292 describe: "debounced mutation",
1293 describeResult: "Success",
1294 optimistic() {},
1295 async refetch() {
1296 await delay(10);
1297 },
1298 debounceMs: 50,
1299 key: () => "shared",
1300 });
1301
1302 // Start a call that will fail (enters debounce)
1303 const promise1 = mutation.runAndReturn("fail");
1304
1305 // Immediately override with a successful call (last call wins)
1306 const promise2 = mutation.runAndReturn("success");
1307
1308 // Both promises should resolve with the same successful result
1309 // (because debouncing causes "last call wins")
1310 const result1 = await promise1;
1311 const result2 = await promise2;
1312
1313 assertEquals(result1, "result-success");
1314 assertEquals(result2, "result-success");
1315 assertEquals(callCount, 1); // Only one call executed
1316});
1317
1318test("BlockingMutation - debounce: all promises resolve together", async () => {
1319 const { client } = createTestClient();
1320 const resolvedAt: number[] = [];
1321
1322 const mutation = client.define({
1323 async mutate(_id: string, value: string) {
1324 await delay(20);
1325 return `result-${value}`;
1326 },
1327 describe: "debounced mutation",
1328 describeResult: "Success",
1329 optimistic() {},
1330 async refetch() {},
1331 debounceMs: 50,
1332 });
1333
1334 // Create three rapid calls
1335 const promise1 = mutation.runAndReturn("id", "a").then((result) => {
1336 resolvedAt.push(Date.now());
1337 return result;
1338 });
1339 const promise2 = mutation.runAndReturn("id", "b").then((result) => {
1340 resolvedAt.push(Date.now());
1341 return result;
1342 });
1343 const promise3 = mutation.runAndReturn("id", "c").then((result) => {
1344 resolvedAt.push(Date.now());
1345 return result;
1346 });
1347
1348 const results = await Promise.all([promise1, promise2, promise3]);
1349
1350 // All should resolve with the same value
1351 assertEquals(results, ["result-c", "result-c", "result-c"]);
1352
1353 // All should resolve at approximately the same time (within 10ms)
1354 assertEquals(resolvedAt.length, 3);
1355 const maxDiff = Math.max(...resolvedAt) - Math.min(...resolvedAt);
1356 assertEquals(maxDiff < 10, true);
1357});
1358
1359test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
1360 const { client } = createTestClient();
1361
1362 const mutation = client.define({
1363 async mutate(value: string) {
1364 await delay(10);
1365 return `result-${value}`;
1366 },
1367 describe: "debounced mutation",
1368 describeResult: "Success",
1369 optimistic() {},
1370 async refetch() {},
1371 debounceMs: 100,
1372 });
1373
1374 const key = mutation.key(["test"]);
1375
1376 // Subscribe and unsubscribe to create and delete the channel
1377 const unsubscribe = mutation.subscribe(key, () => {});
1378
1379 // Start a debounced call
1380 mutation.runAndReturn("test");
1381 await delay(10);
1382
1383 // Unsubscribe while debounce is pending
1384 unsubscribe();
1385
1386 // The timer should still fire and the mutation should complete
1387 await delay(120);
1388
1389 // No errors should have occurred
1390 // (If the timer wasn't cleaned up properly, we might see issues)
1391});
1392
1393test("BlockingMutation - debounce: multiple keys debounce independently", async () => {
1394 const { client } = createTestClient();
1395 const mutateArgs: string[] = [];
1396
1397 const mutation = client.define({
1398 async mutate(id: string) {
1399 mutateArgs.push(id);
1400 await delay(10);
1401 return `result-${id}`;
1402 },
1403 describe: "debounced mutation",
1404 describeResult: "Success",
1405 optimistic() {},
1406 async refetch() {},
1407 debounceMs: 50,
1408 key: ({ args }) => args[0],
1409 });
1410
1411 // Rapid calls to different keys
1412 const promise1a = mutation.runAndReturn("key1");
1413 const promise1b = mutation.runAndReturn("key1");
1414 const promise2a = mutation.runAndReturn("key2");
1415 const promise2b = mutation.runAndReturn("key2");
1416
1417 await Promise.all([promise1a, promise1b, promise2a, promise2b]);
1418
1419 // Should have executed once per key
1420 assertEquals(mutateArgs.sort(), ["key1", "key2"]);
1421});
1422
1423test("BlockingMutation - debounce: onSuccess callbacks from last call only", async () => {
1424 const { client } = createTestClient();
1425 const successResults: string[] = [];
1426
1427 const mutation = client.define({
1428 async mutate(value: string) {
1429 await delay(10);
1430 return `result-${value}`;
1431 },
1432 describe: "debounced mutation",
1433 describeResult: "Success",
1434 optimistic({ args, onSuccess }) {
1435 const [value] = args;
1436 onSuccess((result) => {
1437 successResults.push(`${value}->${result}`);
1438 });
1439 },
1440 async refetch() {},
1441 debounceMs: 50,
1442 });
1443
1444 // Make three rapid calls with different onSuccess callbacks
1445 await Promise.all([
1446 mutation.runAndReturn("a"),
1447 mutation.runAndReturn("b"),
1448 mutation.runAndReturn("c"),
1449 ]);
1450
1451 await delay(20);
1452
1453 // Only the last call's onSuccess should have been called
1454 assertEquals(successResults, ["c->result-c"]);
1455});
test/debounced.test.ts+27-27
......@@ -69,7 +69,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => {
6969 let commitCallCount = 0;
7070 let refetchCallCount = 0;
7171
72 const mutation = client.defineDebounced({
72 const mutation = client.defineBatched({
7373 optimistic({ helpers }, amount: number) {
7474 helpers.increment("counter", amount);
7575 },
......@@ -103,7 +103,7 @@ test("DebouncedMutation - run() catches errors", async () => {
103103 testStore.clear();
104104 testStore.set("counter", 0);
105105
106 const mutation = client.defineDebounced({
106 const mutation = client.defineBatched({
107107 optimistic({ helpers }, amount: number) {
108108 helpers.increment("counter", amount);
109109 },
......@@ -131,7 +131,7 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {
131131 testStore.clear();
132132 testStore.set("counter", 0);
133133
134 const mutation = client.defineDebounced({
134 const mutation = client.defineBatched({
135135 optimistic({ helpers }, amount: number) {
136136 helpers.increment("counter", amount);
137137 },
......@@ -166,7 +166,7 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {
166166 let commitCallCount = 0;
167167 const commitArgs: Array<{ initial: number; current: number }> = [];
168168
169 const mutation = client.defineDebounced({
169 const mutation = client.defineBatched({
170170 optimistic({ helpers }, amount: number) {
171171 helpers.increment("counter", amount);
172172 },
......@@ -209,7 +209,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
209209
210210 let commitCallCount = 0;
211211
212 const mutation = client.defineDebounced({
212 const mutation = client.defineBatched({
213213 optimistic({ helpers }, amount: number) {
214214 helpers.increment("counter", amount);
215215 },
......@@ -259,7 +259,7 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>
259259 let commitCallCount = 0;
260260 const commitArgs: Array<{ initial: number; current: number }> = [];
261261
262 const mutation = client.defineDebounced({
262 const mutation = client.defineBatched({
263263 optimistic({ helpers }, amount: number) {
264264 helpers.increment("counter", amount);
265265 },
......@@ -305,7 +305,7 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()
305305 let commitTime = 0;
306306 const startTime = Date.now();
307307
308 const mutation = client.defineDebounced({
308 const mutation = client.defineBatched({
309309 optimistic({ helpers }, amount: number) {
310310 helpers.increment("counter", amount);
311311 },
......@@ -336,7 +336,7 @@ test("DebouncedMutation - throttle batches calls within time window", async () =
336336 let commitCallCount = 0;
337337 const commitArgs: Array<{ initial: number; current: number }> = [];
338338
339 const mutation = client.defineDebounced({
339 const mutation = client.defineBatched({
340340 optimistic({ helpers }, amount: number) {
341341 helpers.increment("counter", amount);
342342 },
......@@ -389,7 +389,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
389389
390390 let commitCallCount = 0;
391391
392 const mutation = client.defineDebounced({
392 const mutation = client.defineBatched({
393393 optimistic({ helpers }, amount: number) {
394394 helpers.increment("counter", amount);
395395 },
......@@ -433,7 +433,7 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {
433433
434434 let commitCallCount = 0;
435435
436 const mutation = client.defineDebounced({
436 const mutation = client.defineBatched({
437437 optimistic({ helpers }, amount: number) {
438438 helpers.increment("counter", amount);
439439 },
......@@ -493,7 +493,7 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {
493493
494494 let commitCallCount = 0;
495495
496 const mutation = client.defineDebounced({
496 const mutation = client.defineBatched({
497497 optimistic({ helpers }, count: number) {
498498 helpers.setCount(count);
499499 },
......@@ -546,7 +546,7 @@ test("DebouncedMutation - custom deepEquals function", async () => {
546546 testStore.clear();
547547 testStore.set("counter", 0);
548548
549 const mutation = client.defineDebounced({
549 const mutation = client.defineBatched({
550550 optimistic({ helpers }, amount: number) {
551551 helpers.increment("counter", amount);
552552 },
......@@ -580,7 +580,7 @@ test("DebouncedMutation - rollback on commit error", async () => {
580580 testStore.clear();
581581 testStore.set("counter", 10);
582582
583 const mutation = client.defineDebounced({
583 const mutation = client.defineBatched({
584584 optimistic({ helpers }, amount: number) {
585585 helpers.increment("counter", amount);
586586 },
......@@ -613,7 +613,7 @@ test("DebouncedMutation - error event includes error details", async () => {
613613
614614 const tracker = createEventTracker<number>();
615615
616 const mutation = client.defineDebounced({
616 const mutation = client.defineBatched({
617617 optimistic({ helpers }, amount: number) {
618618 helpers.increment("counter", amount);
619619 },
......@@ -649,7 +649,7 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {
649649 const { client } = createTestClient();
650650 testStore.clear();
651651
652 const mutation = client.defineDebounced({
652 const mutation = client.defineBatched({
653653 optimistic(_ctx, _id: string) {},
654654 mode: "debounce",
655655 time: 20,
......@@ -670,7 +670,7 @@ test("DebouncedMutation - key() can return array", () => {
670670 const { client } = createTestClient();
671671 testStore.clear();
672672
673 const mutation = client.defineDebounced({
673 const mutation = client.defineBatched({
674674 optimistic(_ctx, _id: string) {},
675675 mode: "debounce",
676676 time: 20,
......@@ -698,7 +698,7 @@ test("DebouncedMutation - different keys create separate batches", async () => {
698698
699699 let commitCallCount = 0;
700700
701 const mutation = client.defineDebounced({
701 const mutation = client.defineBatched({
702702 optimistic({ helpers }, key: string, amount: number) {
703703 helpers.increment(`counter-${key}`, amount);
704704 },
......@@ -736,7 +736,7 @@ test("DebouncedMutation - describe() with string", () => {
736736 const { client } = createTestClient();
737737 testStore.clear();
738738
739 const mutation = client.defineDebounced({
739 const mutation = client.defineBatched({
740740 optimistic(_ctx, _amount: number) {},
741741 mode: "debounce",
742742 time: 20,
......@@ -757,7 +757,7 @@ test("DebouncedMutation - describe() with function", () => {
757757 const { client } = createTestClient();
758758 testStore.clear();
759759
760 const mutation = client.defineDebounced({
760 const mutation = client.defineBatched({
761761 optimistic(_ctx, _amount: number) {},
762762 mode: "debounce",
763763 time: 20,
......@@ -783,7 +783,7 @@ test("DebouncedMutation - all pending promises resolve with same result", async
783783 testStore.clear();
784784 testStore.set("counter", 0);
785785
786 const mutation = client.defineDebounced({
786 const mutation = client.defineBatched({
787787 optimistic({ helpers }, amount: number) {
788788 helpers.increment("counter", amount);
789789 },
......@@ -820,7 +820,7 @@ test("DebouncedMutation - all pending promises reject with same error", async ()
820820 testStore.clear();
821821 testStore.set("counter", 0);
822822
823 const mutation = client.defineDebounced({
823 const mutation = client.defineBatched({
824824 optimistic({ helpers }, amount: number) {
825825 helpers.increment("counter", amount);
826826 },
......@@ -864,7 +864,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {
864864
865865 let commitCallCount = 0;
866866
867 const mutation = client.defineDebounced({
867 const mutation = client.defineBatched({
868868 optimistic({ helpers }, amount: number) {
869869 helpers.setValue("nonexistent", amount);
870870 },
......@@ -893,7 +893,7 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (
893893 testStore.clear();
894894 testStore.set("counter", 0);
895895
896 const mutation = client.defineDebounced({
896 const mutation = client.defineBatched({
897897 optimistic({ helpers }, amount: number) {
898898 helpers.increment("counter", amount);
899899 },
......@@ -929,7 +929,7 @@ test("DebouncedMutation - default time is 200ms", async () => {
929929 let commitTime: number | null = null;
930930 const startTime = Date.now();
931931
932 const mutation = client.defineDebounced({
932 const mutation = client.defineBatched({
933933 optimistic({ helpers }, amount: number) {
934934 helpers.increment("counter", amount);
935935 },
......@@ -961,7 +961,7 @@ test("DebouncedMutation - context is passed to getValue", async () => {
961961
962962 let receivedUserId: string | undefined;
963963
964 const mutation = client.defineDebounced({
964 const mutation = client.defineBatched({
965965 optimistic({ helpers }, amount: number) {
966966 helpers.increment("counter", amount);
967967 },
......@@ -993,7 +993,7 @@ test("DebouncedMutation - context is passed to commit", async () => {
993993
994994 let receivedUserId: string | undefined;
995995
996 const mutation = client.defineDebounced({
996 const mutation = client.defineBatched({
997997 optimistic({ helpers }, amount: number) {
998998 helpers.increment("counter", amount);
999999 },
......@@ -1023,7 +1023,7 @@ test("DebouncedMutation - first args are used for commit", async () => {
10231023
10241024 let receivedArgs: [string, number] | undefined;
10251025
1026 const mutation = client.defineDebounced({
1026 const mutation = client.defineBatched({
10271027 optimistic({ helpers }, _label: string, amount: number) {
10281028 helpers.increment("counter", amount);
10291029 },