diff --git a/src/mutation.ts b/src/mutation.ts index a89ffd42202576c0ea68803fa851826b2d7a8f66..29266ff9e0eaeda75dda30e3cce91c65efdacb0c 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -112,11 +112,22 @@ interface PendingDebouncedState { shouldCallGlobalHandler: boolean; } -/** Wrapper for errors that includes the description captured before rollback */ -interface MutationError { - __mutationError: true; - error: unknown; - description: string; +/** Internal wrapper that preserves the pre-rollback description for reporting. */ +class MutationError extends Error { + constructor( + readonly error: unknown, + readonly description: string, + ) { + super(errMessage(error), { cause: error }); + this.name = "MutationError"; + } +} + +function unwrapMutationError(caught: unknown) { + if (caught instanceof MutationError) { + return { error: caught.error, description: caught.description }; + } + return { error: caught, description: null }; } interface Channel { @@ -283,16 +294,12 @@ export class BlockingMutation< onSettled?.({ status: "success", result }); return result; }).catch((caught: unknown) => { - // Extract error and description if this is a wrapped mutation error - const isMutationError = (caught as MutationError)?.__mutationError === true; - const error = isMutationError ? (caught as MutationError).error : caught; - const description = isMutationError ? (caught as MutationError).description : this.describe(...args); + const { error } = unwrapMutationError(caught); - // Call user handlers with the unwrapped error onError?.(error); onSettled?.({ status: "error", error }); - throw caught; + throw error; }); } @@ -326,12 +333,8 @@ export class BlockingMutation< } } }).catch((caught: unknown) => { - // Extract error and description if this is a wrapped mutation error - const isMutationError = (caught as MutationError)?.__mutationError === true; - const error = isMutationError ? (caught as MutationError).error : caught; - const description = isMutationError ? (caught as MutationError).description : this.describe(...args); + const { error, description = this.describe(...args) } = unwrapMutationError(caught); - // Call user handlers with the unwrapped error onError?.(error); onSettled?.({ status: "error", error }); @@ -533,7 +536,7 @@ export class BlockingMutation< }, (error) => { // Capture description BEFORE rollback so it sees optimistic state const description = this.describe(...args); - const wrappedError: MutationError = { __mutationError: true, error, description }; + const wrappedError = new MutationError(error, description); // if an error happens, then every rollback is called in reverse order let next; @@ -801,10 +804,7 @@ export class BlockingMutation< } }, (caught) => { - // Extract error and description if this is a wrapped mutation error - const isMutationError = (caught as MutationError)?.__mutationError === true; - const error = isMutationError ? (caught as MutationError).error : caught; - const description = isMutationError ? (caught as MutationError).description : this.describe(...args); + const { error, description = this.describe(...args) } = unwrapMutationError(caught); // Reject all pending promises with unwrapped error pending.forEach((p) => p.reject(error)); diff --git a/test/ordering.test.ts b/test/ordering.test.ts index b7284a41c2a7a23ac03d175367a2689dd563e7cc..0f4a1b07a4f927b7f36f0df70d7c3f5fbe4cf0d0 100644 --- a/test/ordering.test.ts +++ b/test/ordering.test.ts @@ -103,7 +103,9 @@ test("error case: describe is called before rollback", async () => { assertEquals( describeIndex < restoreIndex, true, - `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${calls.join(", ")}`, + `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${ + calls.join(", ") + }`, ); // Verify describe was called with optimistic state still active diff --git a/test/runWithOptions.test.tsx b/test/runWithOptions.test.tsx index 77f290ac0abe69e884519255415888c1a22570fc..654953936781c9346c466fc4f64d413eff7d302d 100644 --- a/test/runWithOptions.test.tsx +++ b/test/runWithOptions.test.tsx @@ -1,5 +1,5 @@ import { useMutate } from "@clo/react-mutation"; -import { assertEquals } from "@std/assert"; +import { assertEquals, assertStrictEquals } from "@std/assert"; import { act, render, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { test, vi } from "vitest"; @@ -66,3 +66,35 @@ test("runWithOptions should allow react hook to do local handling", async () => assertEquals(renders, []); renders = []; }); + +test("runAsHeadlessPromise rejects with the underlying error", async () => { + const { client, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + const error = new Error("damn!"); + const localErrors: unknown[] = []; + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + optimistic: () => {}, + describe: "Test the action", + describeResult: "Tested the action", + }); + + const promise = mutTest.runAsHeadlessPromise({ + onError: (error) => localErrors.push(error), + }); + s.throw(error); + + let caught: unknown; + try { + await promise; + } catch (error) { + caught = error; + } + + assertStrictEquals(caught, error); + assertEquals(localErrors, [error]); + assertEquals(errorMessages, []); +});