1import { assertEquals } from "@std/assert";
2import { act, render, screen } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";
4import { type FC, type MouseEventHandler, type ReactNode, StrictMode } from "react";
5import { renderToString } from "react-dom/server";
6import { test, vi } from "vitest";
7import { MutationClient } from "../src/client.ts";
8import { createMutationButton, useMutate } from "../src/react.ts";
9import { createTestMutationClient, IterableStream } from "./share.ts";
10
11test("useMutate - global error and success handling", async () => {
12 vi.useFakeTimers({ shouldAdvanceTime: true });
13 const user = userEvent.setup({ delay: null });
14
15 const { client, successMessages, errorMessages } = createTestMutationClient();
16 const s = new IterableStream<string>();
17
18 const mutTest = client.define({
19 id: "test-1",
20 mutate: async () => {
21 return (await s.next()).value;
22 },
23 describe: "Test the action",
24 describeResult: "Tested the action",
25 optimistic: () => {},
26 });
27
28 let renders: Array<{ isMutating: boolean; isPending: boolean }> = [];
29 function TestComponent() {
30 const { run, isMutating, isPending } = useMutate(mutTest);
31 renders.push({ isMutating, isPending });
32
33 return (
34 <>
35 <button
36 data-testid="a"
37 onClick={() => {
38 run();
39 }}
40 >
41 button
42 </button>
43 </>
44 );
45 }
46
47 render(<TestComponent />);
48 // initial state
49 assertEquals(renders, [{ isMutating: false, isPending: false }]);
50 assertEquals(successMessages, []);
51 assertEquals(errorMessages, []);
52 renders = [];
53 vi.runAllTimers();
54
55 // mutation 1
56 await act(() => user.click(screen.getByTestId("a")));
57 assertEquals(renders, [{ isMutating: true, isPending: false }]);
58 renders = [];
59 await act(() => vi.advanceTimersByTime(150));
60 assertEquals(renders, []);
61 await act(() => vi.advanceTimersByTime(50));
62 assertEquals(renders, [{ isMutating: true, isPending: true }]);
63 renders = [];
64 assertEquals(successMessages, []);
65 assertEquals(errorMessages, []);
66 await act(async () => {
67 s.push("ok");
68 vi.advanceTimersByTime(100);
69 });
70 assertEquals(successMessages, ["Tested the action"]);
71 assertEquals(errorMessages, []);
72 assertEquals(renders, [{ isMutating: false, isPending: false }]);
73 renders = [];
74
75 // mutation 2
76 await act(() => user.click(screen.getByTestId("a")));
77 assertEquals(renders, [{ isMutating: true, isPending: false }]);
78 renders = [];
79 await act(() => vi.advanceTimersByTime(150));
80 assertEquals(renders, []);
81 await act(() => vi.advanceTimersByTime(50));
82 assertEquals(renders, [{ isMutating: true, isPending: true }]);
83 renders = [];
84 assertEquals(successMessages, ["Tested the action"]);
85 assertEquals(errorMessages, []);
86 const error1 = new Error("damn!");
87 await act(async () => {
88 s.throw(error1);
89 vi.advanceTimersByTime(100);
90 });
91 assertEquals(successMessages, ["Tested the action"]);
92 assertEquals(errorMessages, [
93 { error: error1, message: "Could not test the action: damn!" },
94 ]);
95});
96
97test("useMutate - local error and success handling", async () => {
98 vi.useFakeTimers({ shouldAdvanceTime: true });
99 const user = userEvent.setup({ delay: null });
100
101 const { client, successMessages, errorMessages } = createTestMutationClient();
102 const s = new IterableStream<string>();
103
104 const mutTest = client.define({
105 id: "test-2",
106 mutate: async () => {
107 return (await s.next()).value;
108 },
109 describe: "Test the action",
110 describeResult: "Tested the action",
111 optimistic: () => {},
112 });
113
114 let renders: Array<
115 {
116 isMutating: boolean;
117 isPending: boolean;
118 result: string | undefined;
119 error: unknown;
120 errorMessage: string | undefined;
121 isSuccess: boolean;
122 isError: boolean;
123 isOptimisticData: boolean;
124 }
125 > = [];
126 function TestComponent() {
127 const { run, clear, isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData } =
128 useMutate(
129 mutTest,
130 );
131 renders.push({ isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData });
132
133 return (
134 <>
135 <button
136 data-testid="a"
137 onClick={() => {
138 run();
139 }}
140 >
141 button
142 </button>
143 <button
144 data-testid="b"
145 onClick={() => {
146 clear();
147 }}
148 >
149 clear
150 </button>
151 </>
152 );
153 }
154
155 render(<TestComponent />);
156 // initial state
157 assertEquals(renders, [{
158 error: undefined,
159 errorMessage: undefined,
160 isError: false,
161 isMutating: false,
162 isOptimisticData: false,
163 isPending: false,
164 isSuccess: false,
165 result: undefined,
166 }]);
167 assertEquals(successMessages, []);
168 assertEquals(errorMessages, []);
169 renders = [];
170 vi.runAllTimers();
171
172 // mutation 1 - success
173 await act(() => user.click(screen.getByTestId("a")));
174 assertEquals(renders, [{
175 error: undefined,
176 errorMessage: undefined,
177 isError: false,
178 isMutating: true,
179 isOptimisticData: true,
180 isPending: false,
181 isSuccess: false,
182 result: undefined,
183 }]);
184 renders = [];
185 await act(() => vi.advanceTimersByTime(150));
186 assertEquals(renders, []);
187 await act(() => vi.advanceTimersByTime(50));
188 assertEquals(renders, [{
189 error: undefined,
190 errorMessage: undefined,
191 isError: false,
192 isMutating: true,
193 isOptimisticData: true,
194 isPending: true,
195 isSuccess: false,
196 result: undefined,
197 }]);
198 renders = [];
199 assertEquals(successMessages, []);
200 assertEquals(errorMessages, []);
201 await act(async () => {
202 s.push("ok");
203 vi.advanceTimersByTime(100);
204 });
205 assertEquals(successMessages, []);
206 assertEquals(errorMessages, []);
207 assertEquals(renders, [{
208 error: undefined,
209 errorMessage: undefined,
210 isError: false,
211 isMutating: false,
212 isOptimisticData: false,
213 isPending: false,
214 isSuccess: true,
215 result: "ok",
216 }]);
217 renders = [];
218
219 // clear state
220 vi.runAllTimers();
221 await act(() => user.click(screen.getByTestId("b")));
222 assertEquals(renders, [{
223 error: undefined,
224 errorMessage: undefined,
225 isError: false,
226 isMutating: false,
227 isOptimisticData: false,
228 isPending: false,
229 isSuccess: false,
230 result: undefined,
231 }]);
232 renders = [];
233 await act(() => user.click(screen.getByTestId("b")));
234 assertEquals(renders, []); // nothing changed
235 renders = [];
236
237 // mutation 2 - failure
238 await act(() => user.click(screen.getByTestId("a")));
239 assertEquals(renders, [{
240 error: undefined,
241 errorMessage: undefined,
242 isError: false,
243 isMutating: true,
244 isOptimisticData: true,
245 isPending: false,
246 isSuccess: false,
247 result: undefined,
248 }]);
249 renders = [];
250 await act(() => vi.advanceTimersByTime(150));
251 assertEquals(renders, []);
252 await act(() => vi.advanceTimersByTime(50));
253 assertEquals(renders, [{
254 error: undefined,
255 errorMessage: undefined,
256 isError: false,
257 isMutating: true,
258 isOptimisticData: true,
259 isPending: true,
260 isSuccess: false,
261 result: undefined,
262 }]);
263 renders = [];
264 const error1 = new Error("damn!");
265 await act(async () => {
266 s.throw(error1);
267 vi.advanceTimersByTime(100);
268 });
269 assertEquals(renders, [{
270 error: error1,
271 errorMessage: "Could not test the action: damn!",
272 isError: true,
273 isMutating: false,
274 isOptimisticData: false,
275 isPending: false,
276 isSuccess: false,
277 result: undefined,
278 }]);
279 assertEquals(successMessages, []);
280 assertEquals(errorMessages, []);
281});
282
283test("MutationButton should allow args={null} to disable mutation runs", async () => {
284 const user = userEvent.setup({ delay: null });
285 const { client } = createTestMutationClient();
286 const mutate = vi.fn(async (value: number) => value + 1);
287
288 const mutTest = client.define({
289 id: "test-3",
290 mutate,
291 describe: "Test the action",
292 describeResult: "Tested the action",
293 optimistic: () => {},
294 });
295
296 const MutationButtonBase: FC<{
297 children?: ReactNode;
298 disabled?: boolean;
299 isPending: boolean;
300 onClick: MouseEventHandler<HTMLElement> | undefined;
301 }> = function MutationButtonBase({
302 children,
303 disabled,
304 isPending,
305 onClick,
306 }) {
307 return (
308 <button data-testid="a" disabled={disabled || isPending} onClick={onClick}>
309 {children}
310 </button>
311 );
312 };
313 const MutationButton = createMutationButton(MutationButtonBase);
314
315 render(
316 <MutationButton mutation={mutTest} args={null}>
317 button
318 </MutationButton>,
319 );
320
321 assertEquals((screen.getByTestId("a") as HTMLButtonElement).disabled, true);
322 await act(() => user.click(screen.getByTestId("a")));
323
324 assertEquals(mutate.mock.calls, []);
325});
326
327function countedAllowedMutation() {
328 let activeSubscriptions = 0;
329 const subscribers = new Set<() => void>();
330 let allowed = true;
331 const sub = (cb: () => void) => {
332 activeSubscriptions++;
333 subscribers.add(cb);
334 return () => {
335 activeSubscriptions--;
336 subscribers.delete(cb);
337 };
338 };
339 const client = new MutationClient({
340 context: {},
341 userContext: { get: () => null, sub },
342 getOptimisticHelpers: () => ({}),
343 reportError: () => {},
344 });
345 const mutTest = client.define({
346 id: "test-allowed-subscription",
347 mutate: async () => "ok",
348 describe: "Test",
349 describeResult: null,
350 optimistic: () => {},
351 isAllowed: { get: () => allowed, sub },
352 });
353 const setAllowed = (next: boolean) => {
354 allowed = next;
355 subscribers.forEach((cb) => cb());
356 };
357 return { mutTest, setAllowed, activeSubscriptions: () => activeSubscriptions };
358}
359
360test("server rendering never subscribes to allowed state", () => {
361 const { mutTest, activeSubscriptions } = countedAllowedMutation();
362 function App() {
363 useMutate(mutTest);
364 return null;
365 }
366 for (let i = 0; i < 3; i++) renderToString(<App />);
367 assertEquals(activeSubscriptions(), 0);
368});
369
370test("StrictMode keeps the allowed subscription alive", () => {
371 const { mutTest, setAllowed, activeSubscriptions } = countedAllowedMutation();
372 let lastIsAllowed: boolean | null = null;
373 function App() {
374 lastIsAllowed = useMutate(mutTest).isAllowed;
375 return null;
376 }
377 const view = render(
378 <StrictMode>
379 <App />
380 </StrictMode>,
381 );
382 assertEquals(lastIsAllowed, true);
383
384 act(() => setAllowed(false));
385 assertEquals(lastIsAllowed, false);
386
387 view.unmount();
388 assertEquals(activeSubscriptions(), 0);
389});