1import { assertEquals } from "@std/assert";
2import { act, render, screen } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";
4import type { FC, MouseEventHandler, ReactNode } from "react";
5import { test, vi } from "vitest";
6import { bindAsyncCallback, createMutationButton } from "../src/react.ts";
7import { createTestMutationClient, IterableStream } from "./share.ts";
8
9type Snap = Partial<{
10 isMutating: boolean;
11 isSuccess: boolean;
12 isError: boolean;
13 result: string | undefined;
14 errorMessage: string | undefined;
15}>;
16
17test("useAsyncCallback - runs the callback and surfaces the result", async () => {
18 vi.useFakeTimers({ shouldAdvanceTime: true });
19 const user = userEvent.setup({ delay: null });
20 const { client, successMessages, errorMessages } = createTestMutationClient();
21 const useAsyncCallback = bindAsyncCallback(client);
22 const s = new IterableStream<string>();
23
24 let last: Snap = {};
25 function C() {
26 const { run, isMutating, isSuccess, result } = useAsyncCallback(async () => (await s.next()).value);
27 last = { isMutating, isSuccess, result };
28 return <button data-testid="a" onClick={() => run()}>go</button>;
29 }
30
31 render(<C />);
32 vi.runAllTimers();
33
34 await act(() => user.click(screen.getByTestId("a")));
35 assertEquals(last.isMutating, true);
36
37 await act(async () => {
38 s.push("done");
39 vi.advanceTimersByTime(100);
40 });
41 assertEquals(last.isSuccess, true);
42 assertEquals(last.result, "done");
43 // describeResult defaults to null: no global success toast.
44 assertEquals(successMessages, []);
45 assertEquals(errorMessages, []);
46});
47
48test("useAsyncCallback - generic error message when no describe, suppressed globally while watched", async () => {
49 vi.useFakeTimers({ shouldAdvanceTime: true });
50 const user = userEvent.setup({ delay: null });
51 const { client, errorMessages } = createTestMutationClient();
52 const useAsyncCallback = bindAsyncCallback(client);
53
54 let last: Snap = {};
55 function C() {
56 const { run, isError, errorMessage } = useAsyncCallback(async () => {
57 throw new Error("boom");
58 });
59 last = { isError, errorMessage };
60 return <button data-testid="a" onClick={() => run()}>go</button>;
61 }
62
63 render(<C />);
64 vi.runAllTimers();
65
66 await act(async () => {
67 await user.click(screen.getByTestId("a"));
68 vi.advanceTimersByTime(100);
69 });
70 assertEquals(last.isError, true);
71 assertEquals(last.errorMessage, "Something went wrong: boom");
72 // Component watches the error, so the global handler is suppressed.
73 assertEquals(errorMessages, []);
74});
75
76test("useAsyncCallback - global handler fires with describe when the error is unwatched", async () => {
77 vi.useFakeTimers({ shouldAdvanceTime: true });
78 const user = userEvent.setup({ delay: null });
79 const { client, errorMessages } = createTestMutationClient();
80 const useAsyncCallback = bindAsyncCallback(client);
81
82 function C() {
83 const { run } = useAsyncCallback(async () => {
84 throw new Error("boom");
85 }, { describe: "save the draft" });
86 return <button data-testid="a" onClick={() => run()}>go</button>;
87 }
88
89 render(<C />);
90 vi.runAllTimers();
91
92 await act(async () => {
93 await user.click(screen.getByTestId("a"));
94 vi.advanceTimersByTime(100);
95 });
96 assertEquals(errorMessages.map((e) => e.message), ["Could not save the draft: boom"]);
97});
98
99test("useAsyncCallback - tracks the latest describe across rerenders", async () => {
100 vi.useFakeTimers({ shouldAdvanceTime: true });
101 const user = userEvent.setup({ delay: null });
102 const { client, errorMessages } = createTestMutationClient();
103 const useAsyncCallback = bindAsyncCallback(client);
104
105 function C({ label }: { label: string }) {
106 const { run } = useAsyncCallback(async () => {
107 throw new Error("boom");
108 }, { describe: label });
109 return <button data-testid="a" onClick={() => run()}>go</button>;
110 }
111
112 const { rerender } = render(<C label="create the item" />);
113 rerender(<C label="update the item" />);
114 vi.runAllTimers();
115
116 await act(async () => {
117 await user.click(screen.getByTestId("a"));
118 vi.advanceTimersByTime(100);
119 });
120 assertEquals(errorMessages.map((e) => e.message), ["Could not update the item: boom"]);
121});
122
123test("useAsyncCallback - always runs the latest callback closure", async () => {
124 vi.useFakeTimers({ shouldAdvanceTime: true });
125 const user = userEvent.setup({ delay: null });
126 const { client } = createTestMutationClient();
127 const useAsyncCallback = bindAsyncCallback(client);
128
129 let last: Snap = {};
130 function C({ n }: { n: number }) {
131 const { run, result } = useAsyncCallback(async () => `v${n}`);
132 last = { result };
133 return <button data-testid="a" onClick={() => run()}>go</button>;
134 }
135
136 const { rerender } = render(<C n={1} />);
137 rerender(<C n={2} />);
138 vi.runAllTimers();
139
140 await act(async () => {
141 await user.click(screen.getByTestId("a"));
142 vi.advanceTimersByTime(100);
143 });
144 // The mutation object was built on the n=1 render; the ref must pick up n=2.
145 assertEquals(last.result, "v2");
146});
147
148test("useAsyncCallback - in-flight state survives a rerender", async () => {
149 vi.useFakeTimers({ shouldAdvanceTime: true });
150 const user = userEvent.setup({ delay: null });
151 const { client } = createTestMutationClient();
152 const useAsyncCallback = bindAsyncCallback(client);
153 const s = new IterableStream<string>();
154
155 let last: Snap = {};
156 function C({ x }: { x: number }) {
157 const { run, isMutating, isSuccess, result } = useAsyncCallback(async () => (await s.next()).value);
158 last = { isMutating, isSuccess, result };
159 return <button data-testid="a" onClick={() => run()}>go{x}</button>;
160 }
161
162 const { rerender } = render(<C x={1} />);
163 vi.runAllTimers();
164
165 await act(() => user.click(screen.getByTestId("a")));
166 assertEquals(last.isMutating, true);
167
168 // A rerender must not reset the observer and drop the running mutation.
169 rerender(<C x={2} />);
170 assertEquals(last.isMutating, true);
171
172 await act(async () => {
173 s.push("ok");
174 vi.advanceTimersByTime(100);
175 });
176 assertEquals(last.isSuccess, true);
177 assertEquals(last.result, "ok");
178});
179
180test("useAsyncCallback - drives a mutation button directly", async () => {
181 const user = userEvent.setup({ delay: null });
182 const { client } = createTestMutationClient();
183 const useAsyncCallback = bindAsyncCallback(client);
184 const ran = vi.fn(async () => "ok");
185
186 const MutationButtonBase: FC<{
187 children?: ReactNode;
188 disabled?: boolean;
189 isPending: boolean;
190 onClick: MouseEventHandler<HTMLElement> | undefined;
191 }> = function MutationButtonBase({ children, disabled, isPending, onClick }) {
192 return (
193 <button data-testid="a" disabled={disabled || isPending} onClick={onClick}>
194 {children}
195 </button>
196 );
197 };
198 const MutationButton = createMutationButton(MutationButtonBase);
199
200 function C() {
201 const state = useAsyncCallback(ran);
202 return (
203 <MutationButton mutation={state} args={[]}>
204 go
205 </MutationButton>
206 );
207 }
208
209 render(<C />);
210 await act(() => user.click(screen.getByTestId("a")));
211 assertEquals(ran.mock.calls.length, 1);
212});