1import { message as errMessage } from "@clo/lib/error.ts";
2import type { Timer } from "@clo/lib/ts.ts";
3import {
4 type FC,
5 type MouseEvent,
6 type MouseEventHandler,
7 type ReactNode,
8 useCallback,
9 useEffect,
10 useMemo,
11 useRef,
12 useState,
13 useSyncExternalStore,
14} from "react";
15import { jsx } from "react/jsx-runtime";
16import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts";
17import { BlockingMutation, formatFriendlyError } from "./mutation.ts";
18import type { Mutation, RunOptions, SerializableValue } from "./types.ts";
19
20/**
21 * Subscribe to a mutation's status, as well as accessing a local `run` method.
22 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.
23 */
24export function useMutate<
25 Args extends unknown[],
26 Result,
27>(
28 mutation: Mutation<Args, Result> | null,
29): UseMutateResult<Args, Result> {
30 const [_, setRerender] = useState(0);
31 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
32 if (mutation !== observer.mutation) {
33 observer.mutation = mutation;
34 observer.reset();
35 }
36 // The allowed subscription lives in useSyncExternalStore: the server never
37 // subscribes, and StrictMode's simulated remount resubscribes.
38 const subscribeAllowed = useMemo(() => (onChange: () => void) => {
39 if (!mutation) return () => {};
40 const unsubscribeMutation = mutation.subscribeAllowed(onChange);
41 const unsubscribeClient = mutation.client.userContext?.sub(onChange) ?? (() => {});
42 return () => {
43 unsubscribeMutation();
44 unsubscribeClient();
45 };
46 }, [mutation]);
47 // Everything the subscription can change about a render, as one comparable value.
48 const allowedSnapshot = () =>
49 mutation === null ? -1 : (mutation.isAllowed() ? 1 : 0) | (mutation.isUnauthenticated() ? 2 : 0);
50 useSyncExternalStore(subscribeAllowed, allowedSnapshot, allowedSnapshot);
51 useEffect(() => () => void observer.reset(), []);
52 return observer.binding;
53}
54
55export interface AsyncCallbackOptions {
56 /** Phrased for the template `Could not ${describe}`. Omitted yields a generic error message. */
57 describe?: string;
58 /** Success message shown through the client's global handler. Omitted shows nothing. */
59 describeResult?: string | null;
60}
61
62/**
63 * Binds {@link useAsyncCallback} to a client so ad-hoc async callbacks route
64 * their status and errors through it. A codebase re-exports the result once:
65 * `export const useAsyncCallback = bindAsyncCallback(mutations)`.
66 */
67export function bindAsyncCallback(
68 client: MutationClient<object, object, object, string>,
69): <Args extends unknown[], Result>(
70 callback: (...args: Args) => Promise<Result>,
71 options?: AsyncCallbackOptions,
72) => UseMutateResult<Args, Result> {
73 return function useAsyncCallback<Args extends unknown[], Result>(
74 callback: (...args: Args) => Promise<Result>,
75 options?: AsyncCallbackOptions,
76 ): UseMutateResult<Args, Result> {
77 const live = useRef({ callback, options });
78 live.current = { callback, options };
79 // A stable, unregistered mutation with no optimistic state; every field
80 // reads the ref so it tracks the latest callback and options. Args are
81 // never serialized here (no id, no auth replay), so the serializable bound
82 // is cast away locally. Empty describe/describeResult fall through to the
83 // generic error message and no success toast, respectively.
84 const [mutation] = useState(() =>
85 new BlockingMutation<SerializableValue[], Result, false, MutationClientConfig>(
86 client as unknown as MutationClientFromConfig<MutationClientConfig>,
87 {
88 id: "",
89 mutate: (...args) => live.current.callback(...(args as unknown as Args)),
90 optimistic: () => {},
91 describe: () => live.current.options?.describe ?? "",
92 describeResult: () => live.current.options?.describeResult ?? "",
93 refetchOnSuccess: false,
94 },
95 ) as unknown as Mutation<Args, Result>
96 );
97 return useMutate(mutation);
98 };
99}
100
101export type UseMutateResult<Args extends unknown[], Result> =
102 & UseMutateResultBase<Args, Result>
103 & (
104 | UseMutateSuccess<Result>
105 | UseMutateError
106 | UseMutateIdle
107 );
108
109export interface UseMutateResultBase<Args extends unknown[], Result> {
110 run: (...args: Args) => void;
111 runWithOptions: (
112 ..._: [...args: Args, options: RunOptions<Result>]
113 ) => void;
114
115 /** Unset error and success states */
116 clear: () => void;
117 /** Set `error`, useful for input validators or whatever */
118 setError: (error: unknown) => void;
119 /** The arguments of the latest mutation call */
120 args: Args | undefined;
121 /** `true` when controls should be disabled */
122 isDisabled: boolean;
123 /** `true` when a mutation exists and its authentication requirement is satisfied. */
124 isAllowed: boolean;
125}
126
127export interface UseMutateSuccess<Result> {
128 status: "success";
129 result: Result;
130 error: undefined;
131 errorMessage: undefined;
132 /** `true` when a `mutate` function is currently running. */
133 isMutating: false;
134 /** `true` when a loading indicator should be shown. */
135 isPending: false;
136 /** `true` when a mutation has completed and has a result. */
137 isSuccess: true;
138 /** `true` when a mutation has failed. */
139 isError: false;
140 /** `true` when there is optimistic state applied. */
141 isOptimisticData: boolean;
142}
143export interface UseMutateError {
144 status: "error";
145 result: undefined;
146 error: unknown;
147 /** User-friendly in this format: `Could not {action}: {details}` */
148 errorMessage: string;
149 /** `true` when a `mutate` function is currently running. */
150 isMutating: false;
151 /** `true` when a loading indicator should be shown. */
152 isPending: false;
153 /** `true` when a mutation has completed and has a result. */
154 isSuccess: false;
155 /** `true` when a mutation has failed. */
156 isError: true;
157 /** `true` when there is optimistic state applied. */
158 isOptimisticData: boolean;
159}
160export interface UseMutateIdle {
161 status: "idle" | "mutating";
162 result: undefined;
163 error: undefined;
164 errorMessage: undefined;
165 /** `true` when a `mutate` function is currently running. */
166 isMutating: boolean;
167 /** `true` when a loading indicator should be shown. */
168 isPending: boolean;
169 /** `true` when a mutation has completed and has a result. */
170 isSuccess: false;
171 /** `true` when a mutation has failed. */
172 isError: false;
173 /** `true` when there is optimistic state applied. */
174 isOptimisticData: boolean;
175}
176
177type AnyMutationStateWithoutRun<Args extends unknown[], Result> =
178 & Omit<
179 UseMutateIdle,
180 "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage"
181 >
182 & {
183 status: "idle" | "mutating" | "error" | "success";
184 result: undefined | Result;
185 error: undefined | unknown;
186 errorMessage: undefined | string;
187 isSuccess: boolean;
188 isError: boolean;
189 args: Args | undefined;
190 };
191
192export type AnyMutationState<Args extends unknown[], Result> =
193 & AnyMutationStateWithoutRun<Args, Result>
194 & UseMutateResultBase<Args, Result>;
195
196function initialState() {
197 return {
198 status: "idle",
199 result: undefined,
200 error: undefined,
201 errorMessage: undefined,
202 isMutating: false,
203 isPending: false,
204 isSuccess: false,
205 isError: false,
206 isOptimisticData: false,
207 args: undefined,
208 } as const;
209}
210
211class Observer<Args extends unknown[], Result> {
212 setRerender: (fn: number) => void;
213 mutation: Mutation<Args, Result> | null = null;
214 unsubscribe: (() => void) | null = null;
215 currentKey: string | null = null;
216 pendingTimer: Timer | null = null;
217 debounced: boolean = false;
218
219 constructor(setRerender: (fn: number) => void) {
220 this.setRerender = setRerender;
221 }
222
223 watched: Set<string> = new Set();
224 state: AnyMutationStateWithoutRun<Args, Result> = initialState();
225 setState(newState: Partial<AnyMutationStateWithoutRun<Args, Result>>) {
226 let updateUi = false;
227 const current: Record<string, unknown> = this.state;
228 for (const [key, value] of Object.entries(newState)) {
229 if (value !== current[key]) {
230 current[key] = value;
231 updateUi ||= this.watched.has(key);
232 }
233 }
234 if (updateUi) {
235 this.setRerender(Math.random());
236 }
237 }
238
239 reset() {
240 this.unsubscribe?.();
241 this.unsubscribe = null;
242 this.currentKey = null;
243 this.state = initialState();
244 this.debounced = false;
245 }
246
247 resetPending() {
248 this.setState({ isPending: false });
249 if (this.pendingTimer) clearTimeout(this.pendingTimer);
250 this.pendingTimer = null;
251 }
252
253 computeErrorMessage(error: unknown): string | undefined {
254 if (!error) return undefined;
255 const mutation = this.mutation;
256 return formatFriendlyError(
257 this.state.args ? mutation?.describe(...this.state.args) ?? null : null,
258 error,
259 );
260 }
261
262 run(...args: Args) {
263 const mutation = this.mutation;
264 if (!mutation) return;
265 this.setState({ args });
266 const key = mutation.key(args);
267 if (key !== this.currentKey) {
268 this.currentKey = key;
269 this.unsubscribe?.();
270 this.unsubscribe = mutation.subscribe(
271 mutation.key(args),
272 ({ status, error, result, debounced }) => {
273 this.debounced = debounced;
274
275 if (status === "idle") {
276 this.setState({
277 isMutating: false,
278 isOptimisticData: false,
279 });
280 this.resetPending();
281 return;
282 }
283 const hasError = error != null;
284 const hasResult = result != null;
285
286 this.setState({
287 status: hasError
288 ? "error"
289 : hasResult
290 ? "success"
291 : status === "mutating"
292 ? "mutating"
293 : "idle",
294 error: error ?? undefined,
295 errorMessage: this.state.error === error && this.state.errorMessage
296 ? this.state.errorMessage
297 : this.computeErrorMessage(error ?? undefined),
298 result: result ?? undefined,
299 isMutating: status === "mutating",
300 isSuccess: hasResult && !hasError,
301 isError: hasError,
302 isOptimisticData: status === "waiting" || status === "mutating"
303 || status === "refetching",
304 args: hasError || hasResult ? undefined : this.state.args,
305 });
306
307 if (!this.state.isPending && this.state.isMutating && !debounced) {
308 this.pendingTimer = setTimeout(() => {
309 this.pendingTimer = null;
310 this.setState({ isPending: true });
311 }, 200);
312 } else {
313 this.resetPending();
314 }
315 },
316 );
317 }
318 // Use global error/success handling if this usage of the hook doesn't check for
319 // errors or success. This makes it act pretty awesome in terms of defaults.
320 // You don't have to worry about result UI, they'll surface exactly once.
321 const watchesError = this.watched.has("isError")
322 || this.watched.has("error") || this.watched.has("errorMessage");
323 const watchesSuccess = this.watched.has("isSuccess")
324 || this.watched.has("result");
325 const promise = mutation.runWithOptions(
326 ...args,
327 {
328 onSuccessUi: watchesSuccess ? () => {} : undefined,
329 onError: watchesError ? () => {} : undefined,
330 // For debounced mutations, suppress global handlers in runWithOptions
331 // The debounce logic (#enqueueDebouncedCall) will call them once if needed
332 // But only if the component isn't watching success/error
333 } satisfies RunOptions<Result>,
334 );
335 return promise;
336 }
337
338 runWithOptions(...array: [...args: Args, options: RunOptions<Result>]): void {
339 const mutation = this.mutation;
340 if (!mutation) return;
341
342 const args = array.slice() as Args;
343 const options = args.pop() as RunOptions<Result>;
344
345 this.setState({ args });
346 const key = mutation.key(args);
347
348 // Set up subscription if key changed
349 if (key !== this.currentKey) {
350 this.currentKey = key;
351 this.unsubscribe?.();
352 this.unsubscribe = mutation.subscribe(
353 mutation.key(args),
354 ({ status, error, result }) => {
355 if (status === "idle") {
356 this.setState({
357 isMutating: false,
358 isPending: false,
359 isOptimisticData: false,
360 });
361 return;
362 }
363 const hasError = error != null;
364 const hasResult = result != null;
365
366 this.setState({
367 status: hasError
368 ? "error"
369 : hasResult
370 ? "success"
371 : status === "mutating"
372 ? "mutating"
373 : "idle",
374 error: error ?? undefined,
375 errorMessage: this.state.error === error && this.state.errorMessage
376 ? this.state.errorMessage
377 : this.computeErrorMessage(error ?? undefined),
378 result: result ?? undefined,
379 isMutating: status === "mutating",
380 isPending: status === "mutating" || status === "refetching",
381 isSuccess: hasResult && !hasError,
382 isError: hasError,
383 isOptimisticData: status === "waiting" || status === "mutating"
384 || status === "refetching",
385 args: hasError || hasResult ? undefined : this.state.args,
386 });
387 },
388 );
389 }
390
391 // Delegate to the mutation's runWithOptions and return the promise
392 return mutation.runWithOptions(...args, options);
393 }
394
395 binding: UseMutateResult<Args, Result> = ((self: this) => ({
396 run: self.run.bind(self),
397 runWithOptions: self.runWithOptions.bind(self),
398 clear() {
399 self.setState({
400 status: ["error", "success"].includes(self.state.status)
401 ? "idle"
402 : self.state.status,
403 isError: false,
404 isSuccess: false,
405 error: undefined,
406 errorMessage: undefined,
407 result: undefined,
408 });
409 },
410 setError(error: unknown) {
411 self.setState({
412 status: "error",
413 error,
414 errorMessage: errMessage(error),
415 isError: true,
416 isSuccess: false,
417 result: undefined,
418 });
419 },
420 get status() {
421 self.watched.add("status");
422 return self.state.status;
423 },
424 get result() {
425 self.watched.add("result");
426 return self.state.result;
427 },
428 get error() {
429 self.watched.add("error");
430 return self.state.error;
431 },
432 get errorMessage() {
433 self.watched.add("errorMessage");
434 return self.state.errorMessage;
435 },
436 get isMutating() {
437 self.watched.add("isMutating");
438 return self.state.isMutating;
439 },
440 get isAllowed() {
441 return self.mutation?.isAllowed() ?? false;
442 },
443 get isDisabled() {
444 self.watched.add("isMutating");
445 const mutation = self.mutation;
446 if (!mutation || (self.state.isMutating && !self.debounced)) return true;
447 if (mutation.isAllowed()) return false;
448 // Unauthenticated clicks stay enabled when they can route to a sign-in flow.
449 return !(mutation.isUnauthenticated() && mutation.client.handleUnauthenticated);
450 },
451 get isPending() {
452 self.watched.add("isPending");
453 return self.state.isPending;
454 },
455 get isSuccess() {
456 self.watched.add("isSuccess");
457 return self.state.isSuccess;
458 },
459 get isError() {
460 self.watched.add("isError");
461 return self.state.isError;
462 },
463 get isOptimisticData() {
464 self.watched.add("isOptimisticData");
465 return self.state.isOptimisticData;
466 },
467 get args() {
468 self.watched.add("args");
469 return self.state.args;
470 },
471 } as UseMutateResult<Args, Result>))(this);
472}
473
474interface BaseButtonProps {
475 onClick: MouseEventHandler<HTMLElement> | undefined;
476 isPending: boolean;
477}
478
479export interface MutationButtonComponent<Props> {
480 <Args extends unknown[], Result>(
481 props:
482 & MutationButtonProps<Args, Result>
483 & Props,
484 ): ReactNode;
485 displayName?: string;
486}
487
488export interface MutationButtonProps<Args extends unknown[], Result> {
489 mutation:
490 | Mutation<Args, Result>
491 | UseMutateResult<Args, Result>;
492 /** Preventing default will interrupt the mutation */
493 args: Args | null | ((e: MouseEvent) => Args | null);
494 /** Preventing default will interrupt the mutation */
495 onClick?: (e: MouseEvent) => void;
496 disabled?: boolean;
497
498 /** Omitting this will use the global error handler */
499 onError?: (result: unknown) => void;
500 /** Omitting this will use the global success handler */
501 onSuccessUi?: (result: Result) => void;
502 /** Does not prevent the global handler */
503 onSuccessData?: (result: Result) => void;
504 /** Called instead of running an authenticated mutation when no user is available. */
505 onUnauthenticated?: RunOptions<Result>["onUnauthenticated"];
506
507 /** Global event handlers will still be called! */
508 onSettled?: (
509 event: {
510 status: "success";
511 result: Result;
512 } | {
513 status: "error";
514 error: unknown;
515 },
516 ) => void;
517
518 /** Setting this to true will opt out of the behavior that non-allowed buttons are hidden. */
519 showNotAllowed?: boolean;
520}
521
522/**
523 * Wraps a custom button component with logic to execute a mutation. The wrapped
524 * component must accept `onClick` and an `isPending` property. When the inner
525 * component emits `onClick`, that will begin the mutation. This is a trival
526 * abstraction on top of `useMutate`, but with type gymnastics to allow safe
527 * types.
528 */
529export function createMutationButton<Props>(
530 // Prevent calling this function if missing `onClick`
531 base: Required<Props> extends BaseButtonProps ? FC<Props>
532 : "Base component is missing required props",
533): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> {
534 const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>;
535 // apply the generics at a type level to allow `.bind` to work
536 type BareProps = Omit<Props, keyof BaseButtonProps>;
537 const bound = (GenericMutationButton<Props, unknown[], unknown>)
538 // the `as` clause here converts the second and third generic parameter
539 // back into unspecified generics.
540 .bind(null, Component) as MutationButtonComponent<BareProps>;
541 // react devtools loves display names
542 bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`;
543
544 return bound;
545}
546
547type Identity<T> = T;
548type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
549type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
550 & Omit<Props, keyof MutationButtonProps<Args, Result>>
551 & BaseButtonProps
552 & { disabled?: boolean }
553>;
554
555function GenericMutationButton<
556 Props,
557 Args extends unknown[],
558 Result,
559>(
560 Component: ResolveMutationButtonFc<Props, Args, Result>,
561 props: MutationButtonProps<Args, Result> & Props,
562) {
563 const {
564 mutation,
565 args,
566 onClick,
567 disabled: disabledAttr,
568 onError,
569 onSuccessUi,
570 onSuccessData,
571 onUnauthenticated,
572 onSettled,
573 showNotAllowed,
574 ...forwarded
575 } = props;
576 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
577
578 const localHook = useMutate("subscribe" in mutation ? mutation : null);
579 const state = "subscribe" in mutation ? localHook : mutation;
580 const disabled = disabledAttr || args == null || state.isDisabled;
581 const handleClick = useCallback((e: MouseEvent) => {
582 onClick?.(e);
583 if (e.defaultPrevented) return;
584 const computedArgs = typeof args === "function" ? args(e) : args;
585 if (!computedArgs || e.defaultPrevented) return;
586 state.runWithOptions(
587 ...computedArgs,
588 { onSuccessUi, onSuccessData, onError, onUnauthenticated, onSettled },
589 );
590 }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, onUnauthenticated, state]);
591
592 // Buttons the user can never click are hidden; a signed-out `auth: true`
593 // button stays visible when it can route the click to the sign-in flow.
594 if (!showNotAllowed && !state.isAllowed && state.isDisabled) return null;
595
596 // NOTE: the JSR has trouble with JSX syntax for some reason.
597 return jsx(
598 Component,
599 {
600 ...forwarded,
601 disabled,
602 onClick: disabled ? undefined : handleClick,
603 isPending: state.isPending,
604 } satisfies Parameters<typeof Component>[0],
605 );
606}