authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 23:22:34-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 23:22:50-08:00
log53a378b4b8da853ca8250834e7fc040a1cf6ab27
tree555d634ff73a3dbc8d735330594ef0d2f6ae1d21
parente15e516edd86fdbdbe591469271dfa311cc01e23
signaturelock-open Commit is signed but in an unrecognized format.

feat: snapshots


6 files changed, 380 insertions(+), 30 deletions(-)

src/mutation.ts+87-8
...@@ -63,6 +63,12 @@ export interface MutationOptions<...@@ -63,6 +63,12 @@ export interface MutationOptions<
63 * All pending promises resolve with the final result.63 * All pending promises resolve with the final result.
64 */64 */
65 debounceMs?: number;65 debounceMs?: number;
66 /**
67 * Called before and after optimistic updates to detect no-op mutations.
68 * If the snapshots are equal (using deepEquals), the mutation is cancelled.
69 * Only `onSettled` callbacks fire, not `onSuccess` or global handlers.
70 */
71 snapshot?: (context: Config["context"] & { args: Args }) => unknown;
66}72}
6773
68export type OptimisticContext<74export type OptimisticContext<
...@@ -92,11 +98,13 @@ interface PendingDebouncedState<Args extends unknown[], Result> {...@@ -92,11 +98,13 @@ interface PendingDebouncedState<Args extends unknown[], Result> {
92 }>;98 }>;
93 /** Success callbacks from the most recent call */99 /** Success callbacks from the most recent call */
94 onSuccess: Array<(result: Result) => void>;100 onSuccess: Array<(result: Result) => void>;
101 /** Initial snapshot before any debounced calls (for no-op detection) */
102 initialSnapshot?: unknown;
95}103}
96104
97interface Channel<Args extends unknown[], Result, OptimisticHelpers> {105interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
98 listeners: Set<(update: MutationEvent<Result>) => void>;106 listeners: Set<(update: MutationEvent<Result>) => void>;
99 status: "idle" | "waiting" | "mutating" | "refetching";107 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";
100 rollbacks: Array<() => void>;108 rollbacks: Array<() => void>;
101 refetches: Array<() => Promise<void>>;109 refetches: Array<() => Promise<void>>;
102 queue: Array<Item<Args, Result>>;110 queue: Array<Item<Args, Result>>;
...@@ -242,7 +250,7 @@ export class BlockingMutation<...@@ -242,7 +250,7 @@ export class BlockingMutation<
242 const suppressGlobalSuccess = onSuccess !== undefined;250 const suppressGlobalSuccess = onSuccess !== undefined;
243 const suppressGlobalError = onError !== undefined;251 const suppressGlobalError = onError !== undefined;
244252
245 const promise = this.#runAsPromiseWithOptions(args, { onRestore });253 const promise = this.#runWithOptions(args, { onRestore });
246 promise.then((result) => {254 promise.then((result) => {
247 // Call user handlers255 // Call user handlers
248 onSuccess?.(result);256 onSuccess?.(result);
...@@ -268,12 +276,7 @@ export class BlockingMutation<...@@ -268,12 +276,7 @@ export class BlockingMutation<
268 });276 });
269 }277 }
270278
271 /** Calls the mutation, treating the errors as promise rejection. */279 #runWithOptions(
272 runAsPromise(...args: Args): Promise<Result> {
273 return this.#runAsPromiseWithOptions(args, {});
274 }
275
276 #runAsPromiseWithOptions(
277 args: Args,280 args: Args,
278 { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">,281 { onRestore: userOnRestore }: Pick<RunOptions<Result>, "onRestore">,
279 ): Promise<Result> {282 ): Promise<Result> {
...@@ -290,6 +293,11 @@ export class BlockingMutation<...@@ -290,6 +293,11 @@ export class BlockingMutation<
290 return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true);293 return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true);
291 }294 }
292295
296 // Take snapshot before optimistic update (if snapshot function defined)
297 const beforeSnapshot = this.#options.snapshot
298 ? this.#options.snapshot.call(this.#client.context, { args })
299 : undefined;
300
293 // Create shared optimistic helpers instance for the channel if it doesn't exist301 // Create shared optimistic helpers instance for the channel if it doesn't exist
294 if (channel.helpers === null) {302 if (channel.helpers === null) {
295 const onRefetch = (cb: () => Promise<void>) => {303 const onRefetch = (cb: () => Promise<void>) => {
...@@ -357,6 +365,29 @@ export class BlockingMutation<...@@ -357,6 +365,29 @@ export class BlockingMutation<
357 }365 }
358 expired = true;366 expired = true;
359367
368 // Take snapshot after optimistic update and check for no-op
369 if (beforeSnapshot !== undefined) {
370 const afterSnapshot = this.#options.snapshot!.call(
371 this.#client.context,
372 { args },
373 );
374 const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot);
375
376 if (isNoOp) {
377 let next;
378 while (
379 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
380 ) {
381 next();
382 }
383
384 // Notify listeners of skipped status
385 this.#notify(channel, "skipped");
386
387 return new Promise(() => {});
388 }
389 }
390
360 const { promise, resolve, reject } = Promise.withResolvers<Result>();391 const { promise, resolve, reject } = Promise.withResolvers<Result>();
361 channel.queue.push({392 channel.queue.push({
362 args,393 args,
...@@ -460,6 +491,13 @@ export class BlockingMutation<...@@ -460,6 +491,13 @@ export class BlockingMutation<
460 userOnRestore?: () => void,491 userOnRestore?: () => void,
461 fromRunWithOptions = false,492 fromRunWithOptions = false,
462 ): Promise<Result> {493 ): Promise<Result> {
494 // Capture initial snapshot before first debounced call
495 const isFirstDebouncedCall = channel.pendingDebounced === null;
496 let initialSnapshot: unknown;
497 if (isFirstDebouncedCall && this.#options.snapshot) {
498 initialSnapshot = this.#options.snapshot.call(this.#client.context, { args });
499 }
500
463 // If there's a pending debounced call, roll it back501 // If there's a pending debounced call, roll it back
464 if (channel.pendingDebounced) {502 if (channel.pendingDebounced) {
465 this.#rollbackPendingDebounced(channel);503 this.#rollbackPendingDebounced(channel);
...@@ -533,6 +571,45 @@ export class BlockingMutation<...@@ -533,6 +571,45 @@ export class BlockingMutation<
533 }571 }
534 expired = true;572 expired = true;
535573
574 // Check for no-op by comparing to initial snapshot
575 if (this.#options.snapshot) {
576 const currentSnapshot = this.#options.snapshot.call(
577 this.#client.context,
578 { args },
579 );
580 const snapshotToCompare = isFirstDebouncedCall
581 ? initialSnapshot!
582 : channel.pendingDebounced?.initialSnapshot;
583
584 if (
585 snapshotToCompare !== undefined
586 && this.#client.deepEquals(snapshotToCompare, currentSnapshot)
587 ) {
588 // No-op detected - rollback optimistic update
589 let next;
590 while (
591 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
592 ) {
593 next();
594 }
595
596 // Clear debounce timer
597 if (channel.debounceTimer !== null) {
598 clearTimeout(channel.debounceTimer);
599 channel.debounceTimer = null;
600 }
601
602 // Clear pending debounced state
603 channel.pendingDebounced = null;
604
605 // Notify listeners of skipped status
606 this.#notify(channel, "skipped");
607
608 // Return resolved promise
609 return new Promise(() => {});
610 }
611 }
612
536 // Create promise for this call613 // Create promise for this call
537 const { promise, resolve, reject } = Promise.withResolvers<Result>();614 const { promise, resolve, reject } = Promise.withResolvers<Result>();
538615
...@@ -544,6 +621,7 @@ export class BlockingMutation<...@@ -544,6 +621,7 @@ export class BlockingMutation<
544 rollbackCount: rollbacks,621 rollbackCount: rollbacks,
545 pending: [{ resolve, reject }],622 pending: [{ resolve, reject }],
546 onSuccess,623 onSuccess,
624 initialSnapshot: isFirstDebouncedCall ? initialSnapshot : undefined,
547 };625 };
548626
549 // Set status to waiting627 // Set status to waiting
...@@ -555,6 +633,7 @@ export class BlockingMutation<...@@ -555,6 +633,7 @@ export class BlockingMutation<
555 channel.pendingDebounced.rollbackCount = rollbacks;633 channel.pendingDebounced.rollbackCount = rollbacks;
556 channel.pendingDebounced.pending.push({ resolve, reject });634 channel.pendingDebounced.pending.push({ resolve, reject });
557 channel.pendingDebounced.onSuccess = onSuccess;635 channel.pendingDebounced.onSuccess = onSuccess;
636 // Keep the initial snapshot from the first call
558 // Status stays "waiting"637 // Status stays "waiting"
559 }638 }
560639
src/react.ts+7-14
...@@ -253,20 +253,13 @@ class Observer<Args extends unknown[], Result> {...@@ -253,20 +253,13 @@ class Observer<Args extends unknown[], Result> {
253 || this.watched.has("error") || this.watched.has("errorMessage");253 || this.watched.has("error") || this.watched.has("errorMessage");
254 const watchesSuccess = this.watched.has("isSuccess")254 const watchesSuccess = this.watched.has("isSuccess")
255 || this.watched.has("result");255 || this.watched.has("result");
256 const promise = mutation.runAsPromise(...args)256 const promise = mutation.runWithOptions(
257 .then((result) => {257 ...args,
258 if (!watchesSuccess && mutation.describeResult) {258 {
259 const message = mutation.describeResult(args, result);259 onSuccess: watchesSuccess ? () => {} : undefined,
260 if (message && mutation.client.reportSuccess) {260 onError: watchesError ? () => {} : undefined,
261 mutation.client.reportSuccess(message);261 } satisfies RunOptions<Result>,
262 }262 );
263 }
264 });
265 promise.catch((err) => {
266 if (!watchesError) {
267 mutation.client.reportError(formatFriendlyError(mutation.describe(...args), errMessage(err)), err);
268 }
269 });
270 return promise;263 return promise;
271 }264 }
272265
src/types.ts+1-3
...@@ -5,8 +5,6 @@ export interface Mutation<Args extends unknown[], Result> {...@@ -5,8 +5,6 @@ export interface Mutation<Args extends unknown[], Result> {
5 run(...args: Args): void;5 run(...args: Args): void;
6 /** Calls the mutation with custom handlers that can suppress global handlers. */6 /** Calls the mutation with custom handlers that can suppress global handlers. */
7 runWithOptions(...args: [...args: Args, options: RunOptions<Result>]): void;7 runWithOptions(...args: [...args: Args, options: RunOptions<Result>]): void;
8 /** Calling the mutation. Errors are thrown in the promise. */
9 runAsPromise(...args: Args): Promise<Result>;
108
11 /** Returns the concurrency key used for a given set of arguments */9 /** Returns the concurrency key used for a given set of arguments */
12 key(args: Args): string;10 key(args: Args): string;
...@@ -38,7 +36,7 @@ export interface RunOptions<Result> {...@@ -38,7 +36,7 @@ export interface RunOptions<Result> {
38}36}
3937
40export interface MutationEvent<Result> {38export interface MutationEvent<Result> {
41 status: "idle" | "waiting" | "mutating" | "refetching";39 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";
42 result: Result | null;40 result: Result | null;
43 error: unknown;41 error: unknown;
44}42}
test/cases/runWithOptions.test.tsx+9-2
...@@ -16,10 +16,12 @@ test("runWithOptions should allow react hook to do local handling", async () =>...@@ -16,10 +16,12 @@ test("runWithOptions should allow react hook to do local handling", async () =>
16 mutate: async () => {16 mutate: async () => {
17 return (await s.next()).value;17 return (await s.next()).value;
18 },18 },
19 optimistic: ({ onSuccess }) => {
20 onSuccess(() => {});
21 },
22 refetchOnSuccess: false,
19 describe: "Test the action",23 describe: "Test the action",
20 describeResult: "Tested the action",24 describeResult: "Tested the action",
21 optimistic: () => {},
22 refetchOnSuccess: false,
23 });25 });
2426
25 let renders: Array<{ status: string; result: string | undefined }> = [];27 let renders: Array<{ status: string; result: string | undefined }> = [];
...@@ -58,4 +60,9 @@ test("runWithOptions should allow react hook to do local handling", async () =>...@@ -58,4 +60,9 @@ test("runWithOptions should allow react hook to do local handling", async () =>
58 assertEquals(errorMessages, []);60 assertEquals(errorMessages, []);
59 assertEquals(renders, [{ status: "success", result: "ok" }]);61 assertEquals(renders, [{ status: "success", result: "ok" }]);
60 renders = [];62 renders = [];
63 await act(async () => {
64 vi.advanceTimersByTime(10000);
65 });
66 assertEquals(renders, []);
67 renders = [];
61});68});
test/cases/snapshot.test.tsx created+231
...@@ -0,0 +1,231 @@
1import { useMutate } from "@clo/react-mutation";
2import { assert, assertEquals } from "@std/assert";
3import { act, render, screen } from "@testing-library/react";
4import { userEvent } from "@testing-library/user-event";
5import { test, vi } from "vitest";
6import { createTestMutationClient, IterableStream } from "../share.ts";
7
8test("snapshot should skip no-op mutation", async () => {
9 vi.useFakeTimers({ shouldAdvanceTime: true });
10 const user = userEvent.setup({ delay: null });
11
12 const { client, successMessages, errorMessages } = createTestMutationClient();
13 const s = new IterableStream<string>();
14
15 let state = { value: "initial" };
16 let failed = false;
17 const mutUpdate = client.define({
18 mutate: async (newValue: string) => {
19 failed = true;
20 },
21 optimistic: ({ args: [newValue] }) => {
22 state.value = newValue;
23 },
24 snapshot: () => state.value,
25 describe: "Update value",
26 describeResult: "Updated value",
27 });
28
29 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
30 function TestComponent() {
31 const { run, status, isOptimisticData } = useMutate(mutUpdate);
32 renders.push({ status, isOptimisticData });
33 return (
34 <button data-testid="btn" onClick={() => run("initial")}>
35 update
36 </button>
37 );
38 }
39
40 render(<TestComponent />);
41 assertEquals(renders, [{ status: "idle", isOptimisticData: false }]);
42 renders = [];
43
44 // Click to run mutation with same value (no-op)
45 await act(() => user.click(screen.getByTestId("btn")));
46 vi.runAllTimers();
47
48 // Should skip the mutation and return to idle without calling API
49 assert(!failed);
50 assertEquals(state.value, "initial");
51 assertEquals(renders, []); // nothing changed
52 assertEquals(successMessages, []); // nothing happened
53 assertEquals(errorMessages, []);
54});
55
56test("snapshot should allow mutation when value changes", async () => {
57 vi.useFakeTimers({ shouldAdvanceTime: true });
58 const user = userEvent.setup({ delay: null });
59
60 const { client, successMessages, errorMessages } = createTestMutationClient();
61 const s = new IterableStream<string>();
62
63 let state = { value: "initial" };
64
65 const mutUpdate = client.define({
66 mutate: async (newValue: string) => {
67 return (await s.next()).value;
68 },
69 optimistic: ({ args: [newValue] }) => {
70 state.value = newValue;
71 },
72 snapshot: () => state.value,
73 describe: "Update value",
74 describeResult: "Updated value",
75 });
76
77 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
78 function TestComponent() {
79 const { run, status, isOptimisticData } = useMutate(mutUpdate);
80 renders.push({ status, isOptimisticData });
81 return (
82 <button data-testid="btn" onClick={() => run("changed")}>
83 update
84 </button>
85 );
86 }
87
88 render(<TestComponent />);
89 renders = [];
90
91 // Click to run mutation with different value
92 await act(() => user.click(screen.getByTestId("btn")));
93 assertEquals(state.value, "changed"); // Optimistic applied
94 assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]);
95 renders = [];
96
97 // Complete the mutation
98 await act(async () => {
99 s.push("ok");
100 vi.advanceTimersByTime(100);
101 });
102
103 assertEquals(renders, [{ status: "success", isOptimisticData: false }]);
104 assertEquals(successMessages, ["Updated value"]);
105 assertEquals(errorMessages, []);
106});
107
108test("debounced snapshot should skip when final state equals initial", async () => {
109 vi.useFakeTimers({ shouldAdvanceTime: true });
110 const user = userEvent.setup({ delay: null });
111
112 const { client, successMessages, errorMessages } = createTestMutationClient();
113 const s = new IterableStream<string>();
114
115 let state = { value: "initial" };
116
117 const mutations: string[] = [];
118 const mutUpdate = client.define({
119 mutate: async (newValue: string) => {
120 mutations.push(newValue);
121 },
122 optimistic: ({ args: [newValue] }) => {
123 state.value = newValue;
124 },
125 snapshot: () => state.value,
126 debounceMs: 500,
127 refetchOnSuccess: false,
128 describe: "Update value",
129 describeResult: "Updated value",
130 });
131
132 let renders: Array<{ status: string }> = [];
133 function TestComponent() {
134 const { run, status } = useMutate(mutUpdate);
135 renders.push({ status });
136 return (
137 <>
138 <button data-testid="a" onClick={() => run("changed")}>A</button>
139 <button data-testid="b" onClick={() => run("initial")}>B</button>
140 </>
141 );
142 }
143
144 render(<TestComponent />);
145 assertEquals(renders, [{ status: "idle" }]);
146 renders = [];
147
148 // First call - change value
149 await act(() => user.click(screen.getByTestId("a")));
150 assertEquals(state.value, "changed");
151 assertEquals(renders, []); // Debounced mutations don't show mutating, no update
152 assertEquals(mutations, []);
153 renders = [];
154
155 // Second call - revert to initial (no-op overall)
156 await act(() => user.click(screen.getByTestId("b")));
157 assertEquals(state.value, "initial"); // Should be rolled back to initial
158 assertEquals(renders, []); // Still idle
159 assertEquals(successMessages, []);
160 assertEquals(errorMessages, []);
161 assertEquals(mutations, []);
162
163 await act(() => vi.runAllTimers());
164 assertEquals(successMessages, []);
165 assertEquals(errorMessages, []);
166 assertEquals(mutations, []);
167});
168
169test("debounced snapshot should mutate when final differs from initial", async () => {
170 vi.useFakeTimers({ shouldAdvanceTime: true });
171 const user = userEvent.setup({ delay: null });
172
173 const { client, successMessages, errorMessages } = createTestMutationClient();
174 const s = new IterableStream<string>();
175
176 let state = { value: "initial" };
177
178 const mutUpdate = client.define({
179 mutate: async (newValue: string) => {
180 return (await s.next()).value;
181 },
182 optimistic: ({ args: [newValue] }) => {
183 state.value = newValue;
184 },
185 snapshot: () => state.value,
186 debounceMs: 500,
187 refetchOnSuccess: false,
188 describe: "Update value",
189 describeResult: "Updated value",
190 });
191
192 let renders: Array<{ status: string; result: string | undefined }> = [];
193 function TestComponent() {
194 const { run, status, result } = useMutate(mutUpdate);
195 renders.push({ status, result });
196 return (
197 <>
198 <button data-testid="a" onClick={() => run("temp")}>A</button>
199 <button data-testid="b" onClick={() => run("final")}>B</button>
200 </>
201 );
202 }
203
204 render(<TestComponent />);
205 renders = [];
206
207 // First call
208 await act(() => user.click(screen.getByTestId("a")));
209 assertEquals(state.value, "temp");
210 renders = [];
211
212 // Second call - different from initial
213 await act(() => user.click(screen.getByTestId("b")));
214 assertEquals(state.value, "final");
215 renders = [];
216
217 // Wait for debounce
218 await act(async () => {
219 vi.advanceTimersByTime(500);
220 });
221 assertEquals(renders, [{ status: "mutating", result: undefined }]);
222 renders = [];
223
224 // Complete mutation
225 await act(async () => {
226 s.push("ok");
227 vi.advanceTimersByTime(100);
228 });
229 assertEquals(renders, [{ status: "success", result: "ok" }]);
230 assertEquals(successMessages, []);
231});
test/mutations.test.ts+45-3
...@@ -1,6 +1,48 @@...@@ -1,6 +1,48 @@
1import { test } from "vitest";1import { delay } from "@clo/lib/async.ts";
2import { MutationClient } from "@clo/react-mutation";
3import { assertEquals } from "@std/assert";
4import { test, vi } from "vitest";
2import { createTestMutationClient } from "./share.ts";5import { createTestMutationClient } from "./share.ts";
36
4test("apply optimistic update, refetch when done", () => {7test("run executes callbacks in correct order", async () => {
5 const { client } = createTestMutationClient();8 vi.useFakeTimers();
9 const { client, errorMessages, successMessages } = createTestMutationClient();
10 const calls: string[] = [];
11 const mutTest = client.define({
12 mutate: async () => {
13 calls.push("mutate");
14 await delay(100);
15 return "success";
16 },
17 describe: () => {
18 calls.push("describe");
19 return "Test the action";
20 },
21 describeResult: () => {
22 calls.push("describeResult");
23 return "Tested the action";
24 },
25 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
26 onRefetch(async () => void calls.push("refetch"));
27 onSuccess(async () => void calls.push("success"));
28 onRestore(async () => void calls.push("restore"));
29 calls.push("optimistic");
30 },
31 });
32 mutTest.run();
33 await vi.advanceTimersByTimeAsync(50);
34 assertEquals(calls, [
35 "optimistic",
36 "mutate",
37 ]);
38 await vi.advanceTimersByTimeAsync(100);
39 assertEquals(calls, [
40 "optimistic",
41 "mutate",
42 "success",
43 "refetch",
44 "describeResult",
45 ]);
46 assertEquals(errorMessages, []);
47 assertEquals(successMessages, ["Tested the action"]);
6});48});