authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-05-15 13:25:16-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-22 12:53:46-07:00
logcc21b6dbb13e3b9425367a3e4a740ae8478a1633
tree5a1e95444bb8a8c6e550d732a686ba3efd789238
parentdf31aa2b0a826817feb1033cadfde6b4e5215853
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

chore: cleanup MutationError

resolves #18

3 files changed, 57 insertions(+), 23 deletions(-)

src/mutation.ts+21-21
......@@ -112,11 +112,22 @@ interface PendingDebouncedState<Args extends unknown[], Result> {
112112 shouldCallGlobalHandler: boolean;
113113}
114114
115/** Wrapper for errors that includes the description captured before rollback */
116interface MutationError {
117 __mutationError: true;
118 error: unknown;
119 description: string;
115/** Internal wrapper that preserves the pre-rollback description for reporting. */
116class MutationError extends Error {
117 constructor(
118 readonly error: unknown,
119 readonly description: string,
120 ) {
121 super(errMessage(error), { cause: error });
122 this.name = "MutationError";
123 }
124}
125
126function unwrapMutationError(caught: unknown) {
127 if (caught instanceof MutationError) {
128 return { error: caught.error, description: caught.description };
129 }
130 return { error: caught, description: null };
120131}
121132
122133interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
......@@ -283,16 +294,12 @@ export class BlockingMutation<
283294 onSettled?.({ status: "success", result });
284295 return result;
285296 }).catch((caught: unknown) => {
286 // Extract error and description if this is a wrapped mutation error
287 const isMutationError = (caught as MutationError)?.__mutationError === true;
288 const error = isMutationError ? (caught as MutationError).error : caught;
289 const description = isMutationError ? (caught as MutationError).description : this.describe(...args);
297 const { error } = unwrapMutationError(caught);
290298
291 // Call user handlers with the unwrapped error
292299 onError?.(error);
293300 onSettled?.({ status: "error", error });
294301
295 throw caught;
302 throw error;
296303 });
297304 }
298305
......@@ -326,12 +333,8 @@ export class BlockingMutation<
326333 }
327334 }
328335 }).catch((caught: unknown) => {
329 // Extract error and description if this is a wrapped mutation error
330 const isMutationError = (caught as MutationError)?.__mutationError === true;
331 const error = isMutationError ? (caught as MutationError).error : caught;
332 const description = isMutationError ? (caught as MutationError).description : this.describe(...args);
336 const { error, description = this.describe(...args) } = unwrapMutationError(caught);
333337
334 // Call user handlers with the unwrapped error
335338 onError?.(error);
336339 onSettled?.({ status: "error", error });
337340
......@@ -533,7 +536,7 @@ export class BlockingMutation<
533536 }, (error) => {
534537 // Capture description BEFORE rollback so it sees optimistic state
535538 const description = this.describe(...args);
536 const wrappedError: MutationError = { __mutationError: true, error, description };
539 const wrappedError = new MutationError(error, description);
537540
538541 // if an error happens, then every rollback is called in reverse order
539542 let next;
......@@ -801,10 +804,7 @@ export class BlockingMutation<
801804 }
802805 },
803806 (caught) => {
804 // Extract error and description if this is a wrapped mutation error
805 const isMutationError = (caught as MutationError)?.__mutationError === true;
806 const error = isMutationError ? (caught as MutationError).error : caught;
807 const description = isMutationError ? (caught as MutationError).description : this.describe(...args);
807 const { error, description = this.describe(...args) } = unwrapMutationError(caught);
808808
809809 // Reject all pending promises with unwrapped error
810810 pending.forEach((p) => p.reject(error));
test/ordering.test.ts+3-1
......@@ -103,7 +103,9 @@ test("error case: describe is called before rollback", async () => {
103103 assertEquals(
104104 describeIndex < restoreIndex,
105105 true,
106 `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${calls.join(", ")}`,
106 `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${
107 calls.join(", ")
108 }`,
107109 );
108110
109111 // Verify describe was called with optimistic state still active
test/runWithOptions.test.tsx+33-1
......@@ -1,5 +1,5 @@
11import { useMutate } from "@clo/react-mutation";
2import { assertEquals } from "@std/assert";
2import { assertEquals, assertStrictEquals } from "@std/assert";
33import { act, render, screen } from "@testing-library/react";
44import { userEvent } from "@testing-library/user-event";
55import { test, vi } from "vitest";
......@@ -66,3 +66,35 @@ test("runWithOptions should allow react hook to do local handling", async () =>
6666 assertEquals(renders, []);
6767 renders = [];
6868});
69
70test("runAsHeadlessPromise rejects with the underlying error", async () => {
71 const { client, errorMessages } = createTestMutationClient();
72 const s = new IterableStream<string>();
73 const error = new Error("damn!");
74 const localErrors: unknown[] = [];
75
76 const mutTest = client.define({
77 mutate: async () => {
78 return (await s.next()).value;
79 },
80 optimistic: () => {},
81 describe: "Test the action",
82 describeResult: "Tested the action",
83 });
84
85 const promise = mutTest.runAsHeadlessPromise({
86 onError: (error) => localErrors.push(error),
87 });
88 s.throw(error);
89
90 let caught: unknown;
91 try {
92 await promise;
93 } catch (error) {
94 caught = error;
95 }
96
97 assertStrictEquals(caught, error);
98 assertEquals(localErrors, [error]);
99 assertEquals(errorMessages, []);
100});