Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 54 additions & 14 deletions src/hooks/useEscrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function useEscrow<TData = unknown>(
onRollback: (data: TData) => Promise<void>
): UseEscrowReturn<TData> {
const [mutations, setMutations] = useState<OptimisticMutation<TData>[]>([]);
const pendingRef = useRef(new Set<string>());
const activeMutationsRef = useRef<Set<string>>(new Set());

const submit = useCallback(
async (data: TData) => {
Expand All @@ -42,12 +42,13 @@ export function useEscrow<TData = unknown>(
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
Expand All @@ -56,33 +57,39 @@ export function useEscrow<TData = unknown>(
)
);
} 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
? { ...m, status: "failed", error: (error as Error).message }
: 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]
);
Expand All @@ -91,9 +98,42 @@ export function useEscrow<TData = unknown>(
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 {
Expand Down
160 changes: 160 additions & 0 deletions tests/hooks/useEscrow.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
resolveSubmit = resolve;
});
const onChainSubmit = vi.fn().mockReturnValue(submitPromise);
const onRollback = vi.fn().mockResolvedValue(undefined);

const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback));

let promise: Promise<void>;
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<void>((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");
});
});
Loading