From 9bdd861eee07454ca381e08565807a8ccc47fb7d Mon Sep 17 00:00:00 2001 From: Jess52487 Date: Fri, 19 Jun 2026 13:41:45 +0100 Subject: [PATCH] feat: implement optimistic mutation queue with automatic rollback and retry in useEscrow --- src/hooks/useEscrow.ts | 68 ++++++++++++--- tests/hooks/useEscrow.test.ts | 160 ++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 tests/hooks/useEscrow.test.ts diff --git a/src/hooks/useEscrow.ts b/src/hooks/useEscrow.ts index 7ae48f8..1b0dc81 100644 --- a/src/hooks/useEscrow.ts +++ b/src/hooks/useEscrow.ts @@ -28,7 +28,7 @@ export function useEscrow( onRollback: (data: TData) => Promise ): UseEscrowReturn { const [mutations, setMutations] = useState[]>([]); - const pendingRef = useRef(new Set()); + const activeMutationsRef = useRef>(new Set()); const submit = useCallback( async (data: TData) => { @@ -42,12 +42,13 @@ export function useEscrow( confirmedAt: null, }; - setMutations((prev) => [mutation, ...prev]); - pendingRef.current.add(id); + setMutations((prev) => [mutation, ...prev].slice(0, 50)); + activeMutationsRef.current.add(id); try { await onChainSubmit(data); - if (!pendingRef.current.has(id)) return; + if (!activeMutationsRef.current.has(id)) return; + activeMutationsRef.current.delete(id); setMutations((prev) => prev.map((m) => m.id === id @@ -56,7 +57,9 @@ export function useEscrow( ) ); } catch (error) { - if (!pendingRef.current.has(id)) return; + if (!activeMutationsRef.current.has(id)) return; + activeMutationsRef.current.delete(id); + await onRollback(data); setMutations((prev) => prev.map((m) => m.id === id @@ -64,25 +67,29 @@ export function useEscrow( : m ) ); - } finally { - pendingRef.current.delete(id); } }, - [onChainSubmit] + [onChainSubmit, onRollback] ); const rollback = useCallback( - async (mutationId: string) => { - pendingRef.current.delete(mutationId); + (mutationId: string) => { + if (!activeMutationsRef.current.has(mutationId)) return; + const target = mutations.find((m) => m.id === mutationId); - if (!target || target.status !== "pending") return; + if (!target) return; + + activeMutationsRef.current.delete(mutationId); + + onRollback(target.data).catch((err) => { + console.error("Failed to execute onRollback side effect:", err); + }); setMutations((prev) => prev.map((m) => m.id === mutationId ? { ...m, status: "idle", error: "Rolled back" } : m ) ); - await onRollback(target.data); }, [mutations, onRollback] ); @@ -91,9 +98,42 @@ export function useEscrow( async (mutationId: string) => { const target = mutations.find((m) => m.id === mutationId); if (!target || target.status !== "failed") return; - await submit(target.data); + + setMutations((prev) => + prev.map((m) => + m.id === mutationId + ? { ...m, status: "pending", error: null, submittedAt: Date.now() } + : m + ) + ); + + activeMutationsRef.current.add(mutationId); + + try { + await onChainSubmit(target.data); + if (!activeMutationsRef.current.has(mutationId)) return; + activeMutationsRef.current.delete(mutationId); + setMutations((prev) => + prev.map((m) => + m.id === mutationId + ? { ...m, status: "confirmed", confirmedAt: Date.now() } + : m + ) + ); + } catch (error) { + if (!activeMutationsRef.current.has(mutationId)) return; + activeMutationsRef.current.delete(mutationId); + await onRollback(target.data); + setMutations((prev) => + prev.map((m) => + m.id === mutationId + ? { ...m, status: "failed", error: (error as Error).message } + : m + ) + ); + } }, - [mutations, submit] + [mutations, onChainSubmit, onRollback] ); return { diff --git a/tests/hooks/useEscrow.test.ts b/tests/hooks/useEscrow.test.ts new file mode 100644 index 0000000..b46cddc --- /dev/null +++ b/tests/hooks/useEscrow.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useEscrow } from "../../src/hooks/useEscrow"; + +describe("useEscrow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should initialize with empty mutations and pendingCount of 0", () => { + const onChainSubmit = vi.fn().mockResolvedValue(undefined); + const onRollback = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + expect(result.current.mutations).toEqual([]); + expect(result.current.pendingCount).toBe(0); + }); + + it("should apply optimistic update immediately, then confirm on success", async () => { + let resolveSubmit!: () => void; + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve; + }); + const onChainSubmit = vi.fn().mockReturnValue(submitPromise); + const onRollback = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + let promise: Promise; + act(() => { + promise = result.current.submit("data-1"); + }); + + // Check immediate optimistic state + expect(result.current.mutations).toHaveLength(1); + expect(result.current.mutations[0]).toMatchObject({ + data: "data-1", + status: "pending", + error: null, + confirmedAt: null, + }); + expect(result.current.mutations[0].submittedAt).toBeGreaterThan(0); + expect(result.current.pendingCount).toBe(1); + + // Resolve the on-chain submission + await act(async () => { + resolveSubmit(); + await promise; + }); + + // Check confirmed state + expect(result.current.mutations[0].status).toBe("confirmed"); + expect(result.current.mutations[0].confirmedAt).toBeGreaterThan(0); + expect(result.current.pendingCount).toBe(0); + expect(onRollback).not.toHaveBeenCalled(); + }); + + it("should rollback automatically on failure", async () => { + const error = new Error("On-chain error"); + const onChainSubmit = vi.fn().mockRejectedValue(error); + const onRollback = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + await act(async () => { + await result.current.submit("data-1"); + }); + + // Check failed and rolled back state + expect(result.current.mutations[0].status).toBe("failed"); + expect(result.current.mutations[0].error).toBe("On-chain error"); + expect(result.current.pendingCount).toBe(0); + expect(onRollback).toHaveBeenCalledWith("data-1"); + }); + + it("should allow manual rollback of pending mutation", async () => { + let resolveSubmit!: () => void; + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve; + }); + const onChainSubmit = vi.fn().mockReturnValue(submitPromise); + const onRollback = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + act(() => { + result.current.submit("data-1"); + }); + + const mutationId = result.current.mutations[0].id; + expect(result.current.pendingCount).toBe(1); + + await act(async () => { + result.current.rollback(mutationId); + }); + + // Check that manually rolled back status is idle + expect(result.current.mutations[0].status).toBe("idle"); + expect(result.current.pendingCount).toBe(0); + expect(onRollback).toHaveBeenCalledWith("data-1"); + + // Even if onChainSubmit resolves afterwards, it should not confirm or execute again + await act(async () => { + resolveSubmit(); + await submitPromise; + }); + + expect(result.current.mutations[0].status).toBe("idle"); + }); + + it("should allow retrying a failed mutation", async () => { + const error = new Error("Temp error"); + let shouldFail = true; + const onChainSubmit = vi.fn().mockImplementation(() => { + if (shouldFail) { + return Promise.reject(error); + } + return Promise.resolve(); + }); + const onRollback = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + // First attempt fails + await act(async () => { + await result.current.submit("data-retry"); + }); + + expect(result.current.mutations[0].status).toBe("failed"); + expect(onRollback).toHaveBeenCalledTimes(1); + + // Second attempt succeeds + shouldFail = false; + await act(async () => { + await result.current.retry(result.current.mutations[0].id); + }); + + expect(result.current.mutations[0].status).toBe("confirmed"); + expect(result.current.mutations[0].confirmedAt).toBeGreaterThan(0); + expect(onRollback).toHaveBeenCalledTimes(1); // No new rollback + }); + + it("should store mutation history in state with max 50 entries", async () => { + const onChainSubmit = vi.fn().mockResolvedValue(undefined); + const onRollback = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); + + await act(async () => { + for (let i = 0; i < 55; i++) { + await result.current.submit(`data-${i}`); + } + }); + + expect(result.current.mutations).toHaveLength(50); + // The most recent should be at index 0 (data-54) + expect(result.current.mutations[0].data).toBe("data-54"); + // The oldest kept should be data-5 (since 54 down to 5 is 50 items) + expect(result.current.mutations[49].data).toBe("data-5"); + }); +});