authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-16 15:58:20-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-24 13:49:20-07:00
log2aae3ac292152a63ba252833acb8a34f6d9881e6
tree620f5c204174122dce344e093bf1fafad420ce34
parent7ad627cb1d7df5aad122280d12e72244e86cb447
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: useAsyncCallback

Closes #16

4 files changed, 265 insertions(+), 3 deletions(-)

src/mod.ts+2
...@@ -7,6 +7,8 @@ export {...@@ -7,6 +7,8 @@ export {
7} from "./client.ts";7} from "./client.ts";
8export type { MutationOptions, OptimisticContext } from "./mutation.ts";8export type { MutationOptions, OptimisticContext } from "./mutation.ts";
9export {9export {
10 type AsyncCallbackOptions,
11 bindAsyncCallback,
10 createMutationButton,12 createMutationButton,
11 type MutationButtonComponent,13 type MutationButtonComponent,
12 type MutationButtonProps,14 type MutationButtonProps,
src/mutation.ts+1-1
...@@ -942,7 +942,7 @@ export function formatFriendlyError(...@@ -942,7 +942,7 @@ export function formatFriendlyError(
942 description: string | null,942 description: string | null,
943 error: unknown,943 error: unknown,
944) {944) {
945 if (!description || !description[0]) return `Internal Error: ${errMessage(error)}`;945 if (!description || !description[0]) return `Something went wrong: ${errMessage(error)}`;
946 description = description[0].toLowerCase() + description.slice(1);946 description = description[0].toLowerCase() + description.slice(1);
947 return `Could not ${description}: ${errMessage(error)}`;947 return `Could not ${description}: ${errMessage(error)}`;
948}948}
src/react.ts+50-2
...@@ -7,11 +7,13 @@ import {...@@ -7,11 +7,13 @@ import {
7 type ReactNode,7 type ReactNode,
8 useCallback,8 useCallback,
9 useEffect,9 useEffect,
10 useRef,
10 useState,11 useState,
11} from "react";12} from "react";
12import { jsx } from "react/jsx-runtime";13import { jsx } from "react/jsx-runtime";
13import { formatFriendlyError } from "./mutation.ts";14import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts";
14import type { Mutation, RunOptions } from "./types.ts";15import { BlockingMutation, formatFriendlyError } from "./mutation.ts";
16import type { Json, Mutation, RunOptions } from "./types.ts";
1517
16/**18/**
17 * Subscribe to a mutation's status, as well as accessing a local `run` method.19 * Subscribe to a mutation's status, as well as accessing a local `run` method.
...@@ -34,6 +36,52 @@ export function useMutate<...@@ -34,6 +36,52 @@ export function useMutate<
34 return observer.binding;36 return observer.binding;
35}37}
3638
39export interface AsyncCallbackOptions {
40 /** Phrased for the template `Could not ${describe}`. Omitted yields a generic error message. */
41 describe?: string;
42 /** Success message shown through the client's global handler. Omitted shows nothing. */
43 describeResult?: string | null;
44}
45
46/**
47 * Binds {@link useAsyncCallback} to a client so ad-hoc async callbacks route
48 * their status and errors through it. A codebase re-exports the result once:
49 * `export const useAsyncCallback = bindAsyncCallback(mutations)`.
50 */
51export function bindAsyncCallback(
52 client: MutationClient<object, object, object, string>,
53): <Args extends unknown[], Result>(
54 callback: (...args: Args) => Promise<Result>,
55 options?: AsyncCallbackOptions,
56) => UseMutateResult<Args, Result> {
57 return function useAsyncCallback<Args extends unknown[], Result>(
58 callback: (...args: Args) => Promise<Result>,
59 options?: AsyncCallbackOptions,
60 ): UseMutateResult<Args, Result> {
61 const live = useRef({ callback, options });
62 live.current = { callback, options };
63 // A stable, unregistered mutation with no optimistic state; every field
64 // reads the ref so it tracks the latest callback and options. Args are
65 // never serialized here (no id, no auth replay), so the `Json[]` bound is
66 // cast away locally. Empty describe/describeResult fall through to the
67 // generic error message and no success toast, respectively.
68 const [mutation] = useState(() =>
69 new BlockingMutation<Json[], Result, false, MutationClientConfig>(
70 client as unknown as MutationClientFromConfig<MutationClientConfig>,
71 {
72 id: "",
73 mutate: (...args) => live.current.callback(...(args as unknown as Args)),
74 optimistic: () => {},
75 describe: () => live.current.options?.describe ?? "",
76 describeResult: () => live.current.options?.describeResult ?? "",
77 refetchOnSuccess: false,
78 },
79 ) as unknown as Mutation<Args, Result>
80 );
81 return useMutate(mutation);
82 };
83}
84
37export type UseMutateResult<Args extends unknown[], Result> =85export type UseMutateResult<Args extends unknown[], Result> =
38 & UseMutateResultBase<Args, Result>86 & UseMutateResultBase<Args, Result>
39 & (87 & (
test/asyncCallback.test.tsx created+212
...@@ -0,0 +1,212 @@
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});