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> {...@@ -112,11 +112,22 @@ interface PendingDebouncedState<Args extends unknown[], Result> {
112 shouldCallGlobalHandler: boolean;112 shouldCallGlobalHandler: boolean;
113}113}
114114
115/** Wrapper for errors that includes the description captured before rollback */115/** Internal wrapper that preserves the pre-rollback description for reporting. */
116interface MutationError {116class MutationError extends Error {
117 __mutationError: true;117 constructor(
118 error: unknown;118 readonly error: unknown,
119 description: string;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 };
120}131}
121132
122interface Channel<Args extends unknown[], Result, OptimisticHelpers> {133interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
...@@ -283,16 +294,12 @@ export class BlockingMutation<...@@ -283,16 +294,12 @@ export class BlockingMutation<
283 onSettled?.({ status: "success", result });294 onSettled?.({ status: "success", result });
284 return result;295 return result;
285 }).catch((caught: unknown) => {296 }).catch((caught: unknown) => {
286 // Extract error and description if this is a wrapped mutation error297 const { error } = unwrapMutationError(caught);
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);
290298
291 // Call user handlers with the unwrapped error
292 onError?.(error);299 onError?.(error);
293 onSettled?.({ status: "error", error });300 onSettled?.({ status: "error", error });
294301
295 throw caught;302 throw error;
296 });303 });
297 }304 }
298305
...@@ -326,12 +333,8 @@ export class BlockingMutation<...@@ -326,12 +333,8 @@ export class BlockingMutation<
326 }333 }
327 }334 }
328 }).catch((caught: unknown) => {335 }).catch((caught: unknown) => {
329 // Extract error and description if this is a wrapped mutation error336 const { error, description = this.describe(...args) } = unwrapMutationError(caught);
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);
333337
334 // Call user handlers with the unwrapped error
335 onError?.(error);338 onError?.(error);
336 onSettled?.({ status: "error", error });339 onSettled?.({ status: "error", error });
337340
...@@ -533,7 +536,7 @@ export class BlockingMutation<...@@ -533,7 +536,7 @@ export class BlockingMutation<
533 }, (error) => {536 }, (error) => {
534 // Capture description BEFORE rollback so it sees optimistic state537 // Capture description BEFORE rollback so it sees optimistic state
535 const description = this.describe(...args);538 const description = this.describe(...args);
536 const wrappedError: MutationError = { __mutationError: true, error, description };539 const wrappedError = new MutationError(error, description);
537540
538 // if an error happens, then every rollback is called in reverse order541 // if an error happens, then every rollback is called in reverse order
539 let next;542 let next;
...@@ -801,10 +804,7 @@ export class BlockingMutation<...@@ -801,10 +804,7 @@ export class BlockingMutation<
801 }804 }
802 },805 },
803 (caught) => {806 (caught) => {
804 // Extract error and description if this is a wrapped mutation error807 const { error, description = this.describe(...args) } = unwrapMutationError(caught);
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);
808808
809 // Reject all pending promises with unwrapped error809 // Reject all pending promises with unwrapped error
810 pending.forEach((p) => p.reject(error));810 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 () => {...@@ -103,7 +103,9 @@ test("error case: describe is called before rollback", async () => {
103 assertEquals(103 assertEquals(
104 describeIndex < restoreIndex,104 describeIndex < restoreIndex,
105 true,105 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 }`,
107 );109 );
108110
109 // Verify describe was called with optimistic state still active111 // Verify describe was called with optimistic state still active
test/runWithOptions.test.tsx+33-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useMutate } from "@clo/react-mutation";1import { useMutate } from "@clo/react-mutation";
2import { assertEquals } from "@std/assert";2import { assertEquals, assertStrictEquals } from "@std/assert";
3import { act, render, screen } from "@testing-library/react";3import { act, render, screen } from "@testing-library/react";
4import { userEvent } from "@testing-library/user-event";4import { userEvent } from "@testing-library/user-event";
5import { test, vi } from "vitest";5import { test, vi } from "vitest";
...@@ -66,3 +66,35 @@ test("runWithOptions should allow react hook to do local handling", async () =>...@@ -66,3 +66,35 @@ test("runWithOptions should allow react hook to do local handling", async () =>
66 assertEquals(renders, []);66 assertEquals(renders, []);
67 renders = [];67 renders = [];
68});68});
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});