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({...@@ -49,7 +49,7 @@ const queryCounter = queryOptions({
49// await client.invalidateQueries(queryCounter);49// await client.invalidateQueries(queryCounter);
50// },50// },
51// });51// });
52const mutIncrement = mutationClient.defineDebounced({52const mutIncrement = mutationClient.defineBatched({
53 mode: "debounce",53 mode: "debounce",
54 time: 200,54 time: 200,
5555
...@@ -71,7 +71,7 @@ const mutIncrement = mutationClient.defineDebounced({...@@ -71,7 +71,7 @@ const mutIncrement = mutationClient.defineDebounced({
71 describe: "update counter",71 describe: "update counter",
72 describeResult: ({ initial, current }) => {72 describeResult: ({ initial, current }) => {
73 const delta = current - initial;73 const delta = current - initial;
74 return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`;74 return `Counter updated by ${delta > 0 ? "+" : ""}${delta}`;
75 },75 },
76});76});
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 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.4",3 "version": "1.0.0-beta.5",
4 "exports": {4 "exports": {
5 ".": "./src/mod.ts",5 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",6 "./tanstack-query.ts": "./src/tanstack-query.ts",
readme.md+194-91
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1# `@clo/react-mutation`1# `@clo/react-mutation`
22
3Install via [JSR](https://jsr.io/@clo/react-mutation): `npx jsr add @clo/react-mutation`
4
3## Motivation5## Motivation
46
5At work, we found React Query, with a few helper functions, to be extremely7At 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....@@ -10,38 +12,31 @@ patterns and verbose code that is hard to review.
1012
11The primary gains React Mutation provides are13The primary gains React Mutation provides are
1214
13- **Automatic error handling**. If a `useMutate` hook does not observe15- **Automatic result handling**. If a `useMutate` hook does not observe
14 `isError`, unhandled errors will be propagated to a global handler, which can16 `isError`, unhandled errors will be propagated to a global handler, which can
15 display a UI toast. Otherwise, the component can display the error locally.17 display a UI toast. Otherwise, the component can display the error locally.
16- Optimistic helpers allow defining rollbacks and refetching logic independant18- 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.19 of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers)
18- Debounced Mutations are just so awesome to use.20 shows this power in more detail.
21- Easy debouncing and batching utilities.
1922
20## Usage23## 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
35React Mutation starts with a `MutationClient`, which shares global state for an application.25React Mutation starts with a `MutationClient`, which shares global state for an application.
3626
37```ts27```ts
28import { QueryClient } from "@tanstack/react-query";
29import { MutationClient } from "@clo/react-mutation";
30import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation";
31import { showToastUI } from "...";
32
38const queryClient = new QueryClient();33const queryClient = new QueryClient();
39export const mutations = new MutationClient({34export const mutations = new MutationClient({
40 // All properties in `context` are available within every function.35 // All properties in `context` are available within every function.
41 context: {36 context: {
42 client: queryClient,37 client: queryClient,
43 // Can add any easy helpers for your codebase.38 // 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)`)
45 get: (k: QueryKey) => client.getQueryData(k),40 get: (k: QueryKey) => client.getQueryData(k),
46 },41 },
47 42
...@@ -62,17 +57,13 @@ export const mutations = new MutationClient({...@@ -62,17 +57,13 @@ export const mutations = new MutationClient({
62 reportSuccess(userFriendlySuccessMessage: string) {57 reportSuccess(userFriendlySuccessMessage: string) {
63 showToastUI("success", userFriendlyErrorMessage);58 showToastUI("success", userFriendlyErrorMessage);
64 },59 },
65})60});
66
67```61```
6862
69### Blocking Mutations63### Declaring Mutations
7064
71A blocking mutation is defined with `mutations.defineBlocking`. Example use cases:65With a mutation client, you can declare mutations with `mutations.define()`.
7266Start with the API call code, and then add an optimistic updater function.
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.
7667
77```tsx68```tsx
78const queryItemList = queryOptions({ ... });69const queryItemList = queryOptions({ ... });
...@@ -80,19 +71,27 @@ const queryItem = (id: string) => queryOptions({ ... });...@@ -80,19 +71,27 @@ const queryItem = (id: string) => queryOptions({ ... });
8071
81// The convention is to name handlers starting with `mut`72// The convention is to name handlers starting with `mut`
82const mutDeleteItem = mutations.defineBlocking({73const 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.
84 async mutate(id: string) {76 async mutate(id: string) {
85 const response = await fetch(`/items/${id}`, { method: "delete" });77 const response = await fetch(`/items/${id}`, { method: "delete" });
86 if (!response.ok) throw new Error(`HTTP ${response.status}`);78 if (!response.ok) throw new Error(`HTTP ${response.status}`);
79 return response.json();
87 },80 },
88 81
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 }) {
90 // Remove the matching items, but restore and refetch them on failure.84 // Remove the matching items, but restore and refetch them on failure.
91 helpers.arrayRemove(queryItemList, (item) => item === id);85 helpers.arrayRemove(queryItemList, (item) => item === id);
92 // Remove this query from the client, but restore as stale and refetch it on failure.86 // Remove this query from the client, but restore as stale and refetch it on failure.
93 helpers.removeQuery(queryItem);87 helpers.removeQuery(queryItem);
88
89 onSuccess((result) => {
90 // in general case, you may want to apply a success update
91 });
94 },92 },
95 93
94 // These strings are shown in error/success messages, called *after* optimistic state is applied.
96 // Example: `Could not {description}`95 // Example: `Could not {description}`
97 describe({ get, args: [id] }) {96 describe({ get, args: [id] }) {
98 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";97 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";
...@@ -102,8 +101,8 @@ const mutDeleteItem = mutations.defineBlocking({...@@ -102,8 +101,8 @@ const mutDeleteItem = mutations.defineBlocking({
102 describeResult: ({ get, args: [id] }) =>101 describeResult: ({ get, args: [id] }) =>
103 `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,102 `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
104 103
105 // Since the optimistic handler is perfect, there is no need104 // Since the optimistic handler is perfect, there is no need to refetch any
106 // to refetch any data once a success case is hit.105 // data once a success case is hit. This defaults to false for simplicity.
107 refetchOnSuccess: false,106 refetchOnSuccess: false,
108});107});
109108
...@@ -111,8 +110,8 @@ const mutDeleteItem = mutations.defineBlocking({...@@ -111,8 +110,8 @@ const mutDeleteItem = mutations.defineBlocking({
111// indicated through UI toasts from the mutation client.110// indicated through UI toasts from the mutation client.
112export function Example({ id }: { id: string }) {111export function Example({ id }: { id: string }) {
113 const { data: list } = useSuspenseQuery(queryItemList);112 const { data: list } = useSuspenseQuery(queryItemList);
114 const { run } = useMutate(mutDeleteItem);113 const { run, /* isPending, result, error, ... */ } = useMutate(mutDeleteItem);
115 114
116 return list.map((id) => <li key={id}>115 return list.map((id) => <li key={id}>
117 <Item id={id} />116 <Item id={id} />
118 <button onClick={() => run(id)}>delete</button>117 <button onClick={() => run(id)}>delete</button>
...@@ -120,63 +119,6 @@ export function Example({ id }: { id: string }) {...@@ -120,63 +119,6 @@ export function Example({ id }: { id: string }) {
120}119}
121```120```
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
181### Optimistic Updates123### Optimistic Updates
182124
...@@ -189,7 +131,7 @@ The `optimistic` function is given an object with the following APIs...@@ -189,7 +131,7 @@ The `optimistic` function is given an object with the following APIs
189- `onRestore` - add a callback to revert your optimistic update131- `onRestore` - add a callback to revert your optimistic update
190- `onRefetch` - add a callback to fetch data after a success132- `onRefetch` - add a callback to fetch data after a success
191133
192### React Query Optimistic Helpers134#### React Query Optimistic Helpers
193135
194When using React Query, you can opt into some incredible helpers for making it136When using React Query, you can opt into some incredible helpers for making it
195very easy to write Optimistic Updates. Our setup at work is with this client137very 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...@@ -235,8 +177,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
235 - `arrayRemove` - remove items by a `filter` function177 - `arrayRemove` - remove items by a `filter` function
236 - `arrayUpdate` - update items by a `filter` + `update` function178 - `arrayUpdate` - update items by a `filter` + `update` function
237 - `arrayInsertIndex` - insert an item at an index179 - `arrayInsertIndex` - insert an item at an index
238- **experimental**: Queries that are complex options. Each function takes a type-safe180- Queries that are complex objects. Each function takes a type-safe json path to
239 json path to evaluate, but this system has type bugs.181 evaluate, but this system currently has type bugs and is being improved.
240 - `objSet` - set a property182 - `objSet` - set a property
241 - `objSetMany` - set many properties at once183 - `objSetMany` - set many properties at once
242 - `objIncrement` - increment a number184 - `objIncrement` - increment a number
...@@ -247,3 +189,164 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list...@@ -247,3 +189,164 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
247 - `objArrayRemove` - remove items from array by `filter`189 - `objArrayRemove` - remove items from array by `filter`
248 - `objArrayUpdate` - update items in array by `filter` + `update`190 - `objArrayUpdate` - update items in array by `filter` + `update`
249 - `objArrayInsertIndex` - insert an item in an array at an index191 - `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";...@@ -9,7 +9,7 @@ import { message as errMessage } from "@clo/lib/error.ts";
9 * @template Result - the result of the API call9 * @template Result - the result of the API call
10 * @template Config - global values and helpers from `MutationContext`10 * @template Config - global values and helpers from `MutationContext`
11 */11 */
12export interface BlockingMutationOptions<12export interface MutationOptions<
13 Args extends unknown[],13 Args extends unknown[],
14 Result,14 Result,
15 Config extends MutationClientConfig,15 Config extends MutationClientConfig,
...@@ -34,12 +34,17 @@ export interface BlockingMutationOptions<...@@ -34,12 +34,17 @@ export interface BlockingMutationOptions<
34 * Used in success messages.34 * Used in success messages.
35 * Phrase it as a complete success message, e.g., "Deleted item successfully"35 * Phrase it as a complete success message, e.g., "Deleted item successfully"
36 */36 */
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;
38 /**41 /**
39 * Specifying the optimistic strategy is required. To disable, pass an empty42 * Specifying the optimistic strategy is required. To disable, pass an empty
40 * function with a comment to document why it isn't needed.43 * function with a comment to document why it isn't needed.
41 */44 */
42 optimistic: (context: BlockingOptimisticContext<Args, Result, Config>) => void;45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;
43 /**48 /**
44 * Refetch all of the data this mutation could have affected.49 * Refetch all of the data this mutation could have affected.
45 * Normally, optimistic helpers will perform50 * Normally, optimistic helpers will perform
...@@ -57,9 +62,16 @@ export interface BlockingMutationOptions<...@@ -57,9 +62,16 @@ export interface BlockingMutationOptions<
57 * but optimistic updates will apply instantly.62 * but optimistic updates will apply instantly.
58 */63 */
59 key?: (context: Config["context"] & { args: Args }) => string | string[];64 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;
60}72}
6173
62export type BlockingOptimisticContext<74export type OptimisticContext<
63 Args extends unknown[],75 Args extends unknown[],
64 Result,76 Result,
65 Config extends MutationClientConfig,77 Config extends MutationClientConfig,
...@@ -74,14 +86,31 @@ export type BlockingOptimisticContext<...@@ -74,14 +86,31 @@ export type BlockingOptimisticContext<
74 onRefetch: (cb: () => Promise<void>) => void;86 onRefetch: (cb: () => Promise<void>) => void;
75};87};
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> {
78 listeners: Set<(update: MutationEvent<Result>) => void>;104 listeners: Set<(update: MutationEvent<Result>) => void>;
79 status: "idle" | "mutating" | "refetching";105 status: "idle" | "waiting" | "mutating" | "refetching";
80 rollbacks: Array<() => void>;106 rollbacks: Array<() => void>;
81 refetches: Array<() => Promise<void>>;107 refetches: Array<() => Promise<void>>;
82 queue: Array<Item<Args, Result>>;108 queue: Array<Item<Args, Result>>;
83 // Shared optimistic helpers instance for the channel109 // Shared optimistic helpers instance for the channel
84 helpers: OptimisticHelpers | null;110 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;
85}114}
86115
87interface Item<Args extends unknown[], Result> {116interface Item<Args extends unknown[], Result> {
...@@ -97,14 +126,17 @@ export class BlockingMutation<...@@ -97,14 +126,17 @@ export class BlockingMutation<
97 Result,126 Result,
98 Config extends MutationClientConfig,127 Config extends MutationClientConfig,
99> implements Mutation<Args, Result> {128> implements Mutation<Args, Result> {
100 #options: BlockingMutationOptions<Args, Result, Config>;129 #options: MutationOptions<Args, Result, Config>;
101 #client: MutationClientFromConfig<Config>;130 #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();
103 client: MutationClientFromConfig<Config>;135 client: MutationClientFromConfig<Config>;
104136
105 constructor(137 constructor(
106 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,138 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
107 options: BlockingMutationOptions<Args, Result, Config>,139 options: MutationOptions<Args, Result, Config>,
108 ) {140 ) {
109 this.#options = options;141 this.#options = options;
110 this.#client = client;142 this.#client = client;
...@@ -128,6 +160,8 @@ export class BlockingMutation<...@@ -128,6 +160,8 @@ export class BlockingMutation<
128 refetches: [],160 refetches: [],
129 queue: [],161 queue: [],
130 helpers: null,162 helpers: null,
163 debounceTimer: null,
164 pendingDebounced: null,
131 };165 };
132 this.#channels.set(key, channel);166 this.#channels.set(key, channel);
133 }167 }
...@@ -144,7 +178,7 @@ export class BlockingMutation<...@@ -144,7 +178,7 @@ export class BlockingMutation<
144 }178 }
145179
146 #notify(180 #notify(
147 channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>,181 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
148 status: MutationEvent<Result>["status"],182 status: MutationEvent<Result>["status"],
149 result: Result | null = null,183 result: Result | null = null,
150 error: unknown = null,184 error: unknown = null,
...@@ -153,14 +187,30 @@ export class BlockingMutation<...@@ -153,14 +187,30 @@ export class BlockingMutation<
153 channel.listeners.forEach((cb) => cb(event));187 channel.listeners.forEach((cb) => cb(event));
154 }188 }
155189
156 #setIdle(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {190 #setIdle(
157 channel.status = "idle";191 key: string,
158 // Discard any unconsumed refetch callbacks192 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
159 channel.refetches = [];193 ) {
160 this.#notify(channel, "idle", null, null);194 // Check if there are pending debounced calls waiting
161 // Clean up the channel if there are no listeners195 if (channel.pendingDebounced !== null) {
162 if (channel.listeners.size === 0) {196 // Stay in waiting state
163 this.#channels.delete(key);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 }
164 }214 }
165 }215 }
166216
...@@ -192,7 +242,9 @@ export class BlockingMutation<...@@ -192,7 +242,9 @@ export class BlockingMutation<
192 this.#client.reportSuccess(message);242 this.#client.reportSuccess(message);
193 }243 }
194 }).catch((error) => {244 }).catch((error) => {
195 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;245 const message = `Failed to ${this.describe(...args)}: ${
246 errMessage(error)
247 }`;
196 this.#client.reportError(message, error);248 this.#client.reportError(message, error);
197 });249 });
198 }250 }
...@@ -207,6 +259,11 @@ export class BlockingMutation<...@@ -207,6 +259,11 @@ export class BlockingMutation<
207 const key = this.key(args);259 const key = this.key(args);
208 const channel = this.#getOrPutChannel(key);260 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
210 // Create shared optimistic helpers instance for the channel if it doesn't exist267 // Create shared optimistic helpers instance for the channel if it doesn't exist
211 if (channel.helpers === null) {268 if (channel.helpers === null) {
212 const onRefetch = (cb: () => Promise<void>) => {269 const onRefetch = (cb: () => Promise<void>) => {
...@@ -285,7 +342,10 @@ export class BlockingMutation<...@@ -285,7 +342,10 @@ export class BlockingMutation<
285 return promise;342 return promise;
286 }343 }
287344
288 #executeNext(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {345 #executeNext(
346 key: string,
347 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
348 ) {
289 const item = channel.queue.shift();349 const item = channel.queue.shift();
290 if (!item) {350 if (!item) {
291 this.#setIdle(key, channel);351 this.#setIdle(key, channel);
...@@ -316,7 +376,9 @@ export class BlockingMutation<...@@ -316,7 +376,9 @@ export class BlockingMutation<
316 // Report any errors from refetch or callbacks376 // Report any errors from refetch or callbacks
317 results.forEach((result) => {377 results.forEach((result) => {
318 if (result.status === "rejected") {378 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)}`;
320 this.#client.reportError(message, result.reason);382 this.#client.reportError(message, result.reason);
321 }383 }
322 });384 });
...@@ -358,7 +420,9 @@ export class BlockingMutation<...@@ -358,7 +420,9 @@ export class BlockingMutation<
358 // Report any errors from refetch or callbacks420 // Report any errors from refetch or callbacks
359 results.forEach((result) => {421 results.forEach((result) => {
360 if (result.status === "rejected") {422 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)}`;
362 this.#client.reportError(message, result.reason);426 this.#client.reportError(message, result.reason);
363 }427 }
364 });428 });
...@@ -369,4 +433,178 @@ export class BlockingMutation<...@@ -369,4 +433,178 @@ export class BlockingMutation<
369 reject(error);433 reject(error);
370 });434 });
371 }435 }
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 }
372}610}
src/client.ts+10-9
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";1import {
2import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts";2 DebouncedMutation,
3 type DebouncedMutationOptions,
4} from "./debounced.ts";
5import { BlockingMutation, type MutationOptions } from "./blocking.ts";
3import type { Mutation } from "./types.ts";6import type { Mutation } from "./types.ts";
47
5export interface MutationClientConfig {8export interface MutationClientConfig {
...@@ -63,12 +66,10 @@ export class MutationClient<...@@ -63,12 +66,10 @@ export class MutationClient<
63 }66 }
6467
65 /**68 /**
66 * Define a blocking mutation. A mutation blocks the UI until it is complete.69 * Define a standard mutation.
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 */70 */
70 defineBlocking<const Args extends unknown[], Result>(71 define<const Args extends unknown[], Result>(
71 options: BlockingMutationOptions<72 options: MutationOptions<
72 Args,73 Args,
73 Result,74 Result,
74 { context: Context; optimisticHelpers: OptimisticHelpers }75 { context: Context; optimisticHelpers: OptimisticHelpers }
...@@ -82,13 +83,13 @@ export class MutationClient<...@@ -82,13 +83,13 @@ export class MutationClient<
82 }83 }
8384
84 /**85 /**
85 * Define a debounced mutation. Each call to the mutation applies new optimistic86 * Define a batched mutation. Each call to the mutation applies new optimistic
86 * state, and after a debounce or throttle, the new optimistic state is87 * state, and after a debounce or throttle, the new optimistic state is
87 * committed to the API. UI never shows a pending state for debounced mutations. This88 * committed to the API. UI never shows a pending state for debounced mutations. This
88 * works great for auto-saving input fields, follow buttons, and is preferred89 * works great for auto-saving input fields, follow buttons, and is preferred
89 * whenever possible.90 * whenever possible.
90 */91 */
91 defineDebounced<const Args extends unknown[], Result, Optimistic>(92 defineBatched<const Args extends unknown[], Result, Optimistic>(
92 options: DebouncedMutationOptions<93 options: DebouncedMutationOptions<
93 Args,94 Args,
94 Result,95 Result,
src/mod.ts+1-4
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1export type {1export type { MutationOptions, OptimisticContext } from "./blocking.ts";
2 BlockingMutationOptions,
3 BlockingOptimisticContext,
4} from "./blocking.ts";
5export type {2export type {
6 DebouncedCommitContext,3 DebouncedCommitContext,
7 DebouncedMutationOptions,4 DebouncedMutationOptions,
test/blocking.test.ts+486-37
...@@ -49,7 +49,7 @@ test("BlockingMutation - basic mutation success", async () => {...@@ -49,7 +49,7 @@ test("BlockingMutation - basic mutation success", async () => {
49 let mutateCallCount = 0;49 let mutateCallCount = 0;
50 let refetchCallCount = 0;50 let refetchCallCount = 0;
5151
52 const mutation = client.defineBlocking({52 const mutation = client.define({
53 async mutate(value: string) {53 async mutate(value: string) {
54 mutateCallCount++;54 mutateCallCount++;
55 await delay(10);55 await delay(10);
...@@ -78,7 +78,7 @@ test("BlockingMutation - basic mutation success", async () => {...@@ -78,7 +78,7 @@ test("BlockingMutation - basic mutation success", async () => {
78test("BlockingMutation - run() catches errors", async () => {78test("BlockingMutation - run() catches errors", async () => {
79 const { client, errors } = createTestClient();79 const { client, errors } = createTestClient();
8080
81 const mutation = client.defineBlocking({81 const mutation = client.define({
82 async mutate(_value: string) {82 async mutate(_value: string) {
83 throw new Error("mutation failed");83 throw new Error("mutation failed");
84 },84 },
...@@ -98,7 +98,7 @@ test("BlockingMutation - run() catches errors", async () => {...@@ -98,7 +98,7 @@ test("BlockingMutation - run() catches errors", async () => {
98test("BlockingMutation - runAndReturn() rejects on error", async () => {98test("BlockingMutation - runAndReturn() rejects on error", async () => {
99 const { client } = createTestClient();99 const { client } = createTestClient();
100100
101 const mutation = client.defineBlocking({101 const mutation = client.define({
102 async mutate(_value: string) {102 async mutate(_value: string) {
103 throw new Error("mutation failed");103 throw new Error("mutation failed");
104 },104 },
...@@ -119,7 +119,7 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =...@@ -119,7 +119,7 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =
119 const { client } = createTestClient();119 const { client } = createTestClient();
120 testStore.clear();120 testStore.clear();
121121
122 const mutation = client.defineBlocking({122 const mutation = client.define({
123 async mutate(_key: string, value: string) {123 async mutate(_key: string, value: string) {
124 await delay(50);124 await delay(50);
125 return value;125 return value;
...@@ -147,7 +147,7 @@ test("BlockingMutation - rollback on error", async () => {...@@ -147,7 +147,7 @@ test("BlockingMutation - rollback on error", async () => {
147 const { client } = createTestClient();147 const { client } = createTestClient();
148 testStore.clear();148 testStore.clear();
149149
150 const mutation = client.defineBlocking({150 const mutation = client.define({
151 async mutate(_key: string, _value: string) {151 async mutate(_key: string, _value: string) {
152 await delay(10);152 await delay(10);
153 throw new Error("mutation failed");153 throw new Error("mutation failed");
...@@ -171,7 +171,7 @@ test("BlockingMutation - onSuccess callback is called", async () => {...@@ -171,7 +171,7 @@ test("BlockingMutation - onSuccess callback is called", async () => {
171 const { client } = createTestClient();171 const { client } = createTestClient();
172 const successResults: string[] = [];172 const successResults: string[] = [];
173173
174 const mutation = client.defineBlocking({174 const mutation = client.define({
175 async mutate(value: string) {175 async mutate(value: string) {
176 return `result-${value}`;176 return `result-${value}`;
177 },177 },
...@@ -194,7 +194,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>...@@ -194,7 +194,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
194 const { client } = createTestClient();194 const { client } = createTestClient();
195 const executionOrder: string[] = [];195 const executionOrder: string[] = [];
196196
197 const mutation = client.defineBlocking({197 const mutation = client.define({
198 async mutate(id: string) {198 async mutate(id: string) {
199 executionOrder.push(`start-${id}`);199 executionOrder.push(`start-${id}`);
200 await delay(20);200 await delay(20);
...@@ -226,7 +226,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy...@@ -226,7 +226,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
226 const { client } = createTestClient();226 const { client } = createTestClient();
227 const executionOrder: string[] = [];227 const executionOrder: string[] = [];
228228
229 const mutation = client.defineBlocking({229 const mutation = client.define({
230 async mutate(id: string) {230 async mutate(id: string) {
231 executionOrder.push(`start-${id}`);231 executionOrder.push(`start-${id}`);
232 await delay(20);232 await delay(20);
...@@ -256,7 +256,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy...@@ -256,7 +256,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
256test("BlockingMutation - key() returns JSON stringified key", () => {256test("BlockingMutation - key() returns JSON stringified key", () => {
257 const { client } = createTestClient();257 const { client } = createTestClient();
258258
259 const mutation = client.defineBlocking({259 const mutation = client.define({
260 async mutate(id: string) {260 async mutate(id: string) {
261 return id;261 return id;
262 },262 },
...@@ -276,7 +276,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {...@@ -276,7 +276,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {
276test("BlockingMutation - key() defaults to 'shared' when no key function", () => {276test("BlockingMutation - key() defaults to 'shared' when no key function", () => {
277 const { client } = createTestClient();277 const { client } = createTestClient();
278278
279 const mutation = client.defineBlocking({279 const mutation = client.define({
280 async mutate(id: string) {280 async mutate(id: string) {
281 return id;281 return id;
282 },282 },
...@@ -292,7 +292,7 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>...@@ -292,7 +292,7 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>
292test("BlockingMutation - key() can return array", () => {292test("BlockingMutation - key() can return array", () => {
293 const { client } = createTestClient();293 const { client } = createTestClient();
294294
295 const mutation = client.defineBlocking({295 const mutation = client.define({
296 async mutate(_userId: string, _itemId: string) {296 async mutate(_userId: string, _itemId: string) {
297 return "result";297 return "result";
298 },298 },
...@@ -315,7 +315,7 @@ test("BlockingMutation - key() can return array", () => {...@@ -315,7 +315,7 @@ test("BlockingMutation - key() can return array", () => {
315test("BlockingMutation - describe() with string", () => {315test("BlockingMutation - describe() with string", () => {
316 const { client } = createTestClient();316 const { client } = createTestClient();
317317
318 const mutation = client.defineBlocking({318 const mutation = client.define({
319 async mutate(value: string) {319 async mutate(value: string) {
320 return value;320 return value;
321 },321 },
...@@ -331,7 +331,7 @@ test("BlockingMutation - describe() with string", () => {...@@ -331,7 +331,7 @@ test("BlockingMutation - describe() with string", () => {
331test("BlockingMutation - describe() with function", () => {331test("BlockingMutation - describe() with function", () => {
332 const { client } = createTestClient();332 const { client } = createTestClient();
333333
334 const mutation = client.defineBlocking({334 const mutation = client.define({
335 async mutate(id: string) {335 async mutate(id: string) {
336 return id;336 return id;
337 },337 },
...@@ -349,7 +349,7 @@ test("BlockingMutation - describe() with function", () => {...@@ -349,7 +349,7 @@ test("BlockingMutation - describe() with function", () => {
349test("BlockingMutation - describe() receives context", () => {349test("BlockingMutation - describe() receives context", () => {
350 const { client } = createTestClient();350 const { client } = createTestClient();
351351
352 const mutation = client.defineBlocking({352 const mutation = client.define({
353 async mutate(id: string) {353 async mutate(id: string) {
354 return id;354 return id;
355 },355 },
...@@ -371,7 +371,7 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {...@@ -371,7 +371,7 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {
371 const { client } = createTestClient();371 const { client } = createTestClient();
372 const tracker = createEventTracker<string>();372 const tracker = createEventTracker<string>();
373373
374 const mutation = client.defineBlocking({374 const mutation = client.define({
375 async mutate(value: string) {375 async mutate(value: string) {
376 await delay(10);376 await delay(10);
377 return `result-${value}`;377 return `result-${value}`;
...@@ -401,7 +401,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {...@@ -401,7 +401,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
401 const { client } = createTestClient();401 const { client } = createTestClient();
402 const tracker = createEventTracker<string>();402 const tracker = createEventTracker<string>();
403403
404 const mutation = client.defineBlocking({404 const mutation = client.define({
405 async mutate(value: string) {405 async mutate(value: string) {
406 await delay(10);406 await delay(10);
407 return value;407 return value;
...@@ -429,7 +429,7 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {...@@ -429,7 +429,7 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
429 const { client } = createTestClient();429 const { client } = createTestClient();
430 let refetchCallCount = 0;430 let refetchCallCount = 0;
431431
432 const mutation = client.defineBlocking({432 const mutation = client.define({
433 async mutate(_value: string) {433 async mutate(_value: string) {
434 return _value;434 return _value;
435 },435 },
...@@ -451,7 +451,7 @@ test("BlockingMutation - refetch is called on error", async () => {...@@ -451,7 +451,7 @@ test("BlockingMutation - refetch is called on error", async () => {
451 const { client } = createTestClient();451 const { client } = createTestClient();
452 let refetchCallCount = 0;452 let refetchCallCount = 0;
453453
454 const mutation = client.defineBlocking({454 const mutation = client.define({
455 async mutate(_value: string) {455 async mutate(_value: string) {
456 throw new Error("mutation failed");456 throw new Error("mutation failed");
457 },457 },
...@@ -472,7 +472,7 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {...@@ -472,7 +472,7 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {
472 const { client } = createTestClient();472 const { client } = createTestClient();
473 const executionOrder: string[] = [];473 const executionOrder: string[] = [];
474474
475 const mutation = client.defineBlocking({475 const mutation = client.define({
476 async mutate(id: string) {476 async mutate(id: string) {
477 executionOrder.push(`start-${id}`);477 executionOrder.push(`start-${id}`);
478 await delay(10);478 await delay(10);
...@@ -507,7 +507,7 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async...@@ -507,7 +507,7 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async
507 const { client } = createTestClient();507 const { client } = createTestClient();
508 const rollbackOrder: number[] = [];508 const rollbackOrder: number[] = [];
509509
510 const mutation = client.defineBlocking({510 const mutation = client.define({
511 async mutate(_value: string) {511 async mutate(_value: string) {
512 throw new Error("mutation failed");512 throw new Error("mutation failed");
513 },513 },
...@@ -531,7 +531,7 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati...@@ -531,7 +531,7 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati
531 const { client } = createTestClient();531 const { client } = createTestClient();
532 const rollbackOrder: string[] = [];532 const rollbackOrder: string[] = [];
533533
534 const mutation = client.defineBlocking({534 const mutation = client.define({
535 async mutate(id: string) {535 async mutate(id: string) {
536 await delay(10);536 await delay(10);
537 if (id === "fail") {537 if (id === "fail") {
...@@ -565,7 +565,7 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase...@@ -565,7 +565,7 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase
565 const { client } = createTestClient();565 const { client } = createTestClient();
566 let capturedOnRestore: ((cb: () => void) => void) | null = null;566 let capturedOnRestore: ((cb: () => void) => void) | null = null;
567567
568 const mutation = client.defineBlocking({568 const mutation = client.define({
569 async mutate(_value: string) {569 async mutate(_value: string) {
570 return "result";570 return "result";
571 },571 },
...@@ -597,7 +597,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase...@@ -597,7 +597,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
597 const { client } = createTestClient();597 const { client } = createTestClient();
598 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;598 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
599599
600 const mutation = client.defineBlocking({600 const mutation = client.define({
601 async mutate(_value: string) {601 async mutate(_value: string) {
602 return "result";602 return "result";
603 },603 },
...@@ -628,7 +628,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase...@@ -628,7 +628,7 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
628test("BlockingMutation - error during optimistic update is rejected immediately", async () => {628test("BlockingMutation - error during optimistic update is rejected immediately", async () => {
629 const { client } = createTestClient();629 const { client } = createTestClient();
630630
631 const mutation = client.defineBlocking({631 const mutation = client.define({
632 async mutate(_value: string) {632 async mutate(_value: string) {
633 return "result";633 return "result";
634 },634 },
...@@ -651,7 +651,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca...@@ -651,7 +651,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
651 const { client } = createTestClient();651 const { client } = createTestClient();
652 const rollbackOrder: number[] = [];652 const rollbackOrder: number[] = [];
653653
654 const mutation = client.defineBlocking({654 const mutation = client.define({
655 async mutate(_value: string) {655 async mutate(_value: string) {
656 return "result";656 return "result";
657 },657 },
...@@ -675,7 +675,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca...@@ -675,7 +675,7 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
675test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => {675test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => {
676 const { client, errors } = createTestClient();676 const { client, errors } = createTestClient();
677677
678 const mutation = client.defineBlocking({678 const mutation = client.define({
679 async mutate(value: string) {679 async mutate(value: string) {
680 return value;680 return value;
681 },681 },
...@@ -702,7 +702,7 @@ test("BlockingMutation - optimistic function receives args and helpers", async (...@@ -702,7 +702,7 @@ test("BlockingMutation - optimistic function receives args and helpers", async (
702 let receivedArgs: unknown[] | undefined;702 let receivedArgs: unknown[] | undefined;
703 let receivedHelpers: unknown | undefined;703 let receivedHelpers: unknown | undefined;
704704
705 const mutation = client.defineBlocking({705 const mutation = client.define({
706 async mutate(_value: string) {706 async mutate(_value: string) {
707 return "result";707 return "result";
708 },708 },
...@@ -726,7 +726,7 @@ test("BlockingMutation - refetch receives context and args", async () => {...@@ -726,7 +726,7 @@ test("BlockingMutation - refetch receives context and args", async () => {
726 let receivedUserId: string | undefined;726 let receivedUserId: string | undefined;
727 let receivedArgs: unknown[] | undefined;727 let receivedArgs: unknown[] | undefined;
728728
729 const mutation = client.defineBlocking({729 const mutation = client.define({
730 async mutate(_id: string, value: string) {730 async mutate(_id: string, value: string) {
731 return value;731 return value;
732 },732 },
...@@ -749,7 +749,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {...@@ -749,7 +749,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
749 const { client } = createTestClient();749 const { client } = createTestClient();
750 const tracker = createEventTracker<string>();750 const tracker = createEventTracker<string>();
751751
752 const mutation = client.defineBlocking({752 const mutation = client.define({
753 async mutate(_value: string) {753 async mutate(_value: string) {
754 await delay(10);754 await delay(10);
755 throw new Error("mutation failed");755 throw new Error("mutation failed");
...@@ -778,7 +778,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {...@@ -778,7 +778,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
778 const tracker1 = createEventTracker<string>();778 const tracker1 = createEventTracker<string>();
779 const tracker2 = createEventTracker<string>();779 const tracker2 = createEventTracker<string>();
780780
781 const mutation = client.defineBlocking({781 const mutation = client.define({
782 async mutate(value: string) {782 async mutate(value: string) {
783 await delay(5);783 await delay(5);
784 return value;784 return value;
...@@ -806,7 +806,7 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()...@@ -806,7 +806,7 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()
806 const { client } = createTestClient();806 const { client } = createTestClient();
807 const callOrder: string[] = [];807 const callOrder: string[] = [];
808808
809 const mutation = client.defineBlocking({809 const mutation = client.define({
810 async mutate(value: string) {810 async mutate(value: string) {
811 return value;811 return value;
812 },812 },
...@@ -837,7 +837,7 @@ test("BlockingMutation - result is passed to notification on success", async ()...@@ -837,7 +837,7 @@ test("BlockingMutation - result is passed to notification on success", async ()
837 const { client } = createTestClient();837 const { client } = createTestClient();
838 const tracker = createEventTracker<string>();838 const tracker = createEventTracker<string>();
839839
840 const mutation = client.defineBlocking({840 const mutation = client.define({
841 async mutate(value: string) {841 async mutate(value: string) {
842 await delay(5);842 await delay(5);
843 return `result-${value}`;843 return `result-${value}`;
...@@ -868,7 +868,7 @@ test("BlockingMutation - channel is reused for same key", async () => {...@@ -868,7 +868,7 @@ test("BlockingMutation - channel is reused for same key", async () => {
868 const { client } = createTestClient();868 const { client } = createTestClient();
869 const events: string[] = [];869 const events: string[] = [];
870870
871 const mutation = client.defineBlocking({871 const mutation = client.define({
872 async mutate(value: string) {872 async mutate(value: string) {
873 events.push(`mutate-${value}`);873 events.push(`mutate-${value}`);
874 return value;874 return value;
...@@ -894,7 +894,7 @@ test("BlockingMutation - channel is reused for same key", async () => {...@@ -894,7 +894,7 @@ test("BlockingMutation - channel is reused for same key", async () => {
894test("BlockingMutation - empty queue after all mutations complete", async () => {894test("BlockingMutation - empty queue after all mutations complete", async () => {
895 const { client } = createTestClient();895 const { client } = createTestClient();
896896
897 const mutation = client.defineBlocking({897 const mutation = client.define({
898 async mutate(value: string) {898 async mutate(value: string) {
899 await delay(5);899 await delay(5);
900 return value;900 return value;
...@@ -929,7 +929,7 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()...@@ -929,7 +929,7 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()
929 const { client } = createTestClient();929 const { client } = createTestClient();
930 const results: string[] = [];930 const results: string[] = [];
931931
932 const mutation = client.defineBlocking({932 const mutation = client.define({
933 async mutate(value: string) {933 async mutate(value: string) {
934 return value;934 return value;
935 },935 },
...@@ -953,7 +953,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {...@@ -953,7 +953,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
953 const { client } = createTestClient();953 const { client } = createTestClient();
954 let refetchCalled = false;954 let refetchCalled = false;
955955
956 const mutation = client.defineBlocking({956 const mutation = client.define({
957 async mutate(value: string) {957 async mutate(value: string) {
958 return value;958 return value;
959 },959 },
...@@ -976,7 +976,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {...@@ -976,7 +976,7 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
976test("BlockingMutation - refetch error after mutation failure is reported", async () => {976test("BlockingMutation - refetch error after mutation failure is reported", async () => {
977 const { client, errors } = createTestClient();977 const { client, errors } = createTestClient();
978978
979 const mutation = client.defineBlocking({979 const mutation = client.define({
980 async mutate(_value: string) {980 async mutate(_value: string) {
981 throw new Error("mutation failed");981 throw new Error("mutation failed");
982 },982 },
...@@ -1004,3 +1004,452 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn...@@ -1004,3 +1004,452 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn
1004 "refetch also failed",1004 "refetch also failed",
1005 );1005 );
1006});1006});
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 () => {...@@ -69,7 +69,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => {
69 let commitCallCount = 0;69 let commitCallCount = 0;
70 let refetchCallCount = 0;70 let refetchCallCount = 0;
7171
72 const mutation = client.defineDebounced({72 const mutation = client.defineBatched({
73 optimistic({ helpers }, amount: number) {73 optimistic({ helpers }, amount: number) {
74 helpers.increment("counter", amount);74 helpers.increment("counter", amount);
75 },75 },
...@@ -103,7 +103,7 @@ test("DebouncedMutation - run() catches errors", async () => {...@@ -103,7 +103,7 @@ test("DebouncedMutation - run() catches errors", async () => {
103 testStore.clear();103 testStore.clear();
104 testStore.set("counter", 0);104 testStore.set("counter", 0);
105105
106 const mutation = client.defineDebounced({106 const mutation = client.defineBatched({
107 optimistic({ helpers }, amount: number) {107 optimistic({ helpers }, amount: number) {
108 helpers.increment("counter", amount);108 helpers.increment("counter", amount);
109 },109 },
...@@ -131,7 +131,7 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {...@@ -131,7 +131,7 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {
131 testStore.clear();131 testStore.clear();
132 testStore.set("counter", 0);132 testStore.set("counter", 0);
133133
134 const mutation = client.defineDebounced({134 const mutation = client.defineBatched({
135 optimistic({ helpers }, amount: number) {135 optimistic({ helpers }, amount: number) {
136 helpers.increment("counter", amount);136 helpers.increment("counter", amount);
137 },137 },
...@@ -166,7 +166,7 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {...@@ -166,7 +166,7 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {
166 let commitCallCount = 0;166 let commitCallCount = 0;
167 const commitArgs: Array<{ initial: number; current: number }> = [];167 const commitArgs: Array<{ initial: number; current: number }> = [];
168168
169 const mutation = client.defineDebounced({169 const mutation = client.defineBatched({
170 optimistic({ helpers }, amount: number) {170 optimistic({ helpers }, amount: number) {
171 helpers.increment("counter", amount);171 helpers.increment("counter", amount);
172 },172 },
...@@ -209,7 +209,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {...@@ -209,7 +209,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
209209
210 let commitCallCount = 0;210 let commitCallCount = 0;
211211
212 const mutation = client.defineDebounced({212 const mutation = client.defineBatched({
213 optimistic({ helpers }, amount: number) {213 optimistic({ helpers }, amount: number) {
214 helpers.increment("counter", amount);214 helpers.increment("counter", amount);
215 },215 },
...@@ -259,7 +259,7 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>...@@ -259,7 +259,7 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>
259 let commitCallCount = 0;259 let commitCallCount = 0;
260 const commitArgs: Array<{ initial: number; current: number }> = [];260 const commitArgs: Array<{ initial: number; current: number }> = [];
261261
262 const mutation = client.defineDebounced({262 const mutation = client.defineBatched({
263 optimistic({ helpers }, amount: number) {263 optimistic({ helpers }, amount: number) {
264 helpers.increment("counter", amount);264 helpers.increment("counter", amount);
265 },265 },
...@@ -305,7 +305,7 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()...@@ -305,7 +305,7 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()
305 let commitTime = 0;305 let commitTime = 0;
306 const startTime = Date.now();306 const startTime = Date.now();
307307
308 const mutation = client.defineDebounced({308 const mutation = client.defineBatched({
309 optimistic({ helpers }, amount: number) {309 optimistic({ helpers }, amount: number) {
310 helpers.increment("counter", amount);310 helpers.increment("counter", amount);
311 },311 },
...@@ -336,7 +336,7 @@ test("DebouncedMutation - throttle batches calls within time window", async () =...@@ -336,7 +336,7 @@ test("DebouncedMutation - throttle batches calls within time window", async () =
336 let commitCallCount = 0;336 let commitCallCount = 0;
337 const commitArgs: Array<{ initial: number; current: number }> = [];337 const commitArgs: Array<{ initial: number; current: number }> = [];
338338
339 const mutation = client.defineDebounced({339 const mutation = client.defineBatched({
340 optimistic({ helpers }, amount: number) {340 optimistic({ helpers }, amount: number) {
341 helpers.increment("counter", amount);341 helpers.increment("counter", amount);
342 },342 },
...@@ -389,7 +389,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()...@@ -389,7 +389,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
389389
390 let commitCallCount = 0;390 let commitCallCount = 0;
391391
392 const mutation = client.defineDebounced({392 const mutation = client.defineBatched({
393 optimistic({ helpers }, amount: number) {393 optimistic({ helpers }, amount: number) {
394 helpers.increment("counter", amount);394 helpers.increment("counter", amount);
395 },395 },
...@@ -433,7 +433,7 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {...@@ -433,7 +433,7 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {
433433
434 let commitCallCount = 0;434 let commitCallCount = 0;
435435
436 const mutation = client.defineDebounced({436 const mutation = client.defineBatched({
437 optimistic({ helpers }, amount: number) {437 optimistic({ helpers }, amount: number) {
438 helpers.increment("counter", amount);438 helpers.increment("counter", amount);
439 },439 },
...@@ -493,7 +493,7 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {...@@ -493,7 +493,7 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {
493493
494 let commitCallCount = 0;494 let commitCallCount = 0;
495495
496 const mutation = client.defineDebounced({496 const mutation = client.defineBatched({
497 optimistic({ helpers }, count: number) {497 optimistic({ helpers }, count: number) {
498 helpers.setCount(count);498 helpers.setCount(count);
499 },499 },
...@@ -546,7 +546,7 @@ test("DebouncedMutation - custom deepEquals function", async () => {...@@ -546,7 +546,7 @@ test("DebouncedMutation - custom deepEquals function", async () => {
546 testStore.clear();546 testStore.clear();
547 testStore.set("counter", 0);547 testStore.set("counter", 0);
548548
549 const mutation = client.defineDebounced({549 const mutation = client.defineBatched({
550 optimistic({ helpers }, amount: number) {550 optimistic({ helpers }, amount: number) {
551 helpers.increment("counter", amount);551 helpers.increment("counter", amount);
552 },552 },
...@@ -580,7 +580,7 @@ test("DebouncedMutation - rollback on commit error", async () => {...@@ -580,7 +580,7 @@ test("DebouncedMutation - rollback on commit error", async () => {
580 testStore.clear();580 testStore.clear();
581 testStore.set("counter", 10);581 testStore.set("counter", 10);
582582
583 const mutation = client.defineDebounced({583 const mutation = client.defineBatched({
584 optimistic({ helpers }, amount: number) {584 optimistic({ helpers }, amount: number) {
585 helpers.increment("counter", amount);585 helpers.increment("counter", amount);
586 },586 },
...@@ -613,7 +613,7 @@ test("DebouncedMutation - error event includes error details", async () => {...@@ -613,7 +613,7 @@ test("DebouncedMutation - error event includes error details", async () => {
613613
614 const tracker = createEventTracker<number>();614 const tracker = createEventTracker<number>();
615615
616 const mutation = client.defineDebounced({616 const mutation = client.defineBatched({
617 optimistic({ helpers }, amount: number) {617 optimistic({ helpers }, amount: number) {
618 helpers.increment("counter", amount);618 helpers.increment("counter", amount);
619 },619 },
...@@ -649,7 +649,7 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {...@@ -649,7 +649,7 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {
649 const { client } = createTestClient();649 const { client } = createTestClient();
650 testStore.clear();650 testStore.clear();
651651
652 const mutation = client.defineDebounced({652 const mutation = client.defineBatched({
653 optimistic(_ctx, _id: string) {},653 optimistic(_ctx, _id: string) {},
654 mode: "debounce",654 mode: "debounce",
655 time: 20,655 time: 20,
...@@ -670,7 +670,7 @@ test("DebouncedMutation - key() can return array", () => {...@@ -670,7 +670,7 @@ test("DebouncedMutation - key() can return array", () => {
670 const { client } = createTestClient();670 const { client } = createTestClient();
671 testStore.clear();671 testStore.clear();
672672
673 const mutation = client.defineDebounced({673 const mutation = client.defineBatched({
674 optimistic(_ctx, _id: string) {},674 optimistic(_ctx, _id: string) {},
675 mode: "debounce",675 mode: "debounce",
676 time: 20,676 time: 20,
...@@ -698,7 +698,7 @@ test("DebouncedMutation - different keys create separate batches", async () => {...@@ -698,7 +698,7 @@ test("DebouncedMutation - different keys create separate batches", async () => {
698698
699 let commitCallCount = 0;699 let commitCallCount = 0;
700700
701 const mutation = client.defineDebounced({701 const mutation = client.defineBatched({
702 optimistic({ helpers }, key: string, amount: number) {702 optimistic({ helpers }, key: string, amount: number) {
703 helpers.increment(`counter-${key}`, amount);703 helpers.increment(`counter-${key}`, amount);
704 },704 },
...@@ -736,7 +736,7 @@ test("DebouncedMutation - describe() with string", () => {...@@ -736,7 +736,7 @@ test("DebouncedMutation - describe() with string", () => {
736 const { client } = createTestClient();736 const { client } = createTestClient();
737 testStore.clear();737 testStore.clear();
738738
739 const mutation = client.defineDebounced({739 const mutation = client.defineBatched({
740 optimistic(_ctx, _amount: number) {},740 optimistic(_ctx, _amount: number) {},
741 mode: "debounce",741 mode: "debounce",
742 time: 20,742 time: 20,
...@@ -757,7 +757,7 @@ test("DebouncedMutation - describe() with function", () => {...@@ -757,7 +757,7 @@ test("DebouncedMutation - describe() with function", () => {
757 const { client } = createTestClient();757 const { client } = createTestClient();
758 testStore.clear();758 testStore.clear();
759759
760 const mutation = client.defineDebounced({760 const mutation = client.defineBatched({
761 optimistic(_ctx, _amount: number) {},761 optimistic(_ctx, _amount: number) {},
762 mode: "debounce",762 mode: "debounce",
763 time: 20,763 time: 20,
...@@ -783,7 +783,7 @@ test("DebouncedMutation - all pending promises resolve with same result", async...@@ -783,7 +783,7 @@ test("DebouncedMutation - all pending promises resolve with same result", async
783 testStore.clear();783 testStore.clear();
784 testStore.set("counter", 0);784 testStore.set("counter", 0);
785785
786 const mutation = client.defineDebounced({786 const mutation = client.defineBatched({
787 optimistic({ helpers }, amount: number) {787 optimistic({ helpers }, amount: number) {
788 helpers.increment("counter", amount);788 helpers.increment("counter", amount);
789 },789 },
...@@ -820,7 +820,7 @@ test("DebouncedMutation - all pending promises reject with same error", async ()...@@ -820,7 +820,7 @@ test("DebouncedMutation - all pending promises reject with same error", async ()
820 testStore.clear();820 testStore.clear();
821 testStore.set("counter", 0);821 testStore.set("counter", 0);
822822
823 const mutation = client.defineDebounced({823 const mutation = client.defineBatched({
824 optimistic({ helpers }, amount: number) {824 optimistic({ helpers }, amount: number) {
825 helpers.increment("counter", amount);825 helpers.increment("counter", amount);
826 },826 },
...@@ -864,7 +864,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {...@@ -864,7 +864,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {
864864
865 let commitCallCount = 0;865 let commitCallCount = 0;
866866
867 const mutation = client.defineDebounced({867 const mutation = client.defineBatched({
868 optimistic({ helpers }, amount: number) {868 optimistic({ helpers }, amount: number) {
869 helpers.setValue("nonexistent", amount);869 helpers.setValue("nonexistent", amount);
870 },870 },
...@@ -893,7 +893,7 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (...@@ -893,7 +893,7 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (
893 testStore.clear();893 testStore.clear();
894 testStore.set("counter", 0);894 testStore.set("counter", 0);
895895
896 const mutation = client.defineDebounced({896 const mutation = client.defineBatched({
897 optimistic({ helpers }, amount: number) {897 optimistic({ helpers }, amount: number) {
898 helpers.increment("counter", amount);898 helpers.increment("counter", amount);
899 },899 },
...@@ -929,7 +929,7 @@ test("DebouncedMutation - default time is 200ms", async () => {...@@ -929,7 +929,7 @@ test("DebouncedMutation - default time is 200ms", async () => {
929 let commitTime: number | null = null;929 let commitTime: number | null = null;
930 const startTime = Date.now();930 const startTime = Date.now();
931931
932 const mutation = client.defineDebounced({932 const mutation = client.defineBatched({
933 optimistic({ helpers }, amount: number) {933 optimistic({ helpers }, amount: number) {
934 helpers.increment("counter", amount);934 helpers.increment("counter", amount);
935 },935 },
...@@ -961,7 +961,7 @@ test("DebouncedMutation - context is passed to getValue", async () => {...@@ -961,7 +961,7 @@ test("DebouncedMutation - context is passed to getValue", async () => {
961961
962 let receivedUserId: string | undefined;962 let receivedUserId: string | undefined;
963963
964 const mutation = client.defineDebounced({964 const mutation = client.defineBatched({
965 optimistic({ helpers }, amount: number) {965 optimistic({ helpers }, amount: number) {
966 helpers.increment("counter", amount);966 helpers.increment("counter", amount);
967 },967 },
...@@ -993,7 +993,7 @@ test("DebouncedMutation - context is passed to commit", async () => {...@@ -993,7 +993,7 @@ test("DebouncedMutation - context is passed to commit", async () => {
993993
994 let receivedUserId: string | undefined;994 let receivedUserId: string | undefined;
995995
996 const mutation = client.defineDebounced({996 const mutation = client.defineBatched({
997 optimistic({ helpers }, amount: number) {997 optimistic({ helpers }, amount: number) {
998 helpers.increment("counter", amount);998 helpers.increment("counter", amount);
999 },999 },
...@@ -1023,7 +1023,7 @@ test("DebouncedMutation - first args are used for commit", async () => {...@@ -1023,7 +1023,7 @@ test("DebouncedMutation - first args are used for commit", async () => {
10231023
1024 let receivedArgs: [string, number] | undefined;1024 let receivedArgs: [string, number] | undefined;
10251025
1026 const mutation = client.defineDebounced({1026 const mutation = client.defineBatched({
1027 optimistic({ helpers }, _label: string, amount: number) {1027 optimistic({ helpers }, _label: string, amount: number) {
1028 helpers.increment("counter", amount);1028 helpers.increment("counter", amount);
1029 },1029 },