authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 15:27:52-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 16:27:26-07:00
logec65067d04a72a51b71b63d3b4569843b8fd0dad
tree4c6b2c80c359b4f033c409b80c8551915991d986
parent2f39b6a4fe105ba99110ed4a2b9b63124cccbfcc
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: pass `args={null}` to disable a mutation button

resolves #14

2 files changed, 70 insertions(+), 17 deletions(-)

src/react.ts+25-16
......@@ -150,7 +150,7 @@ class Observer<Args extends unknown[], Result> {
150150 unsubscribe: (() => void) | null = null;
151151 currentKey: string | null = null;
152152 pendingTimer: Timer | null = null;
153 debounced: boolean;
153 debounced: boolean = false;
154154
155155 constructor(setRerender: (fn: number) => void) {
156156 this.setRerender = setRerender;
......@@ -236,7 +236,7 @@ class Observer<Args extends unknown[], Result> {
236236 isSuccess: hasResult && !hasError,
237237 isError: hasError,
238238 isOptimisticData: status === "waiting" || status === "mutating"
239 || status === "refetching" || (hasError && status !== "idle"),
239 || status === "refetching",
240240 args: hasError || hasResult ? undefined : this.state.args,
241241 });
242242
......@@ -317,7 +317,7 @@ class Observer<Args extends unknown[], Result> {
317317 isSuccess: hasResult && !hasError,
318318 isError: hasError,
319319 isOptimisticData: status === "waiting" || status === "mutating"
320 || status === "refetching" || (hasError && status !== "idle"),
320 || status === "refetching",
321321 args: hasError || hasResult ? undefined : this.state.args,
322322 });
323323 },
......@@ -420,14 +420,17 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
420420 | Mutation<Args, Result>
421421 | UseMutateResult<Args, Result>;
422422 /** Preventing default will interrupt the mutation */
423 args: Args | ((e: MouseEvent) => Args | null);
423 args: Args | null | ((e: MouseEvent) => Args | null);
424424 /** Preventing default will interrupt the mutation */
425425 onClick?: (e: MouseEvent) => void;
426 disabled?: boolean;
426427
427428 /** Omitting this will use the global error handler */
428429 onError?: (result: unknown) => void;
429430 /** Omitting this will use the global success handler */
430 onSuccess?: (result: Result) => void;
431 onSuccessUi?: (result: Result) => void;
432 /** Does not prevent the global handler */
433 onSuccessData?: (result: Result) => void;
431434
432435 /** Global event handlers will still be called! */
433436 onSettled?: (
......@@ -471,6 +474,7 @@ type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
471474type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
472475 & Omit<Props, keyof MutationButtonProps<Args, Result>>
473476 & BaseButtonProps
477 & { disabled?: boolean }
474478>;
475479
476480function GenericMutationButton<
......@@ -485,8 +489,10 @@ function GenericMutationButton<
485489 mutation,
486490 args,
487491 onClick,
492 disabled: disabledAttr,
488493 onError,
489 onSuccess,
494 onSuccessUi,
495 onSuccessData,
490496 onSettled,
491497 ...forwarded
492498 } = props;
......@@ -494,22 +500,25 @@ function GenericMutationButton<
494500
495501 const localHook = useMutate("subscribe" in mutation ? mutation : null);
496502 const state = "subscribe" in mutation ? localHook : mutation;
503 const disabled = disabledAttr || args == null || state.isDisabled;
504 const handleClick = useCallback((e: MouseEvent) => {
505 onClick?.(e);
506 if (e.defaultPrevented) return;
507 const computedArgs = typeof args === "function" ? args(e) : args;
508 if (!computedArgs || e.defaultPrevented) return;
509 state.runWithOptions(
510 ...computedArgs,
511 { onSuccessUi, onSuccessData, onError, onSettled },
512 );
513 }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, state]);
497514
498515 // NOTE: the JSR has trouble with JSX syntax for some reason.
499516 return jsx(
500517 Component,
501518 {
502519 ...forwarded,
503 onClick: useCallback((e: MouseEvent) => {
504 onClick?.(e);
505 if (e.defaultPrevented) return;
506 const computedArgs = typeof args === "function" ? args(e) : args;
507 if (!computedArgs || e.defaultPrevented) return;
508 state.runWithOptions(
509 ...computedArgs,
510 { onSuccess, onError, onSettled },
511 );
512 }, [state]),
520 disabled,
521 onClick: disabled ? undefined : handleClick,
513522 isPending: state.isPending,
514523 } satisfies Parameters<typeof Component>[0],
515524 );
test/useMutate.test.tsx+45-1
......@@ -1,8 +1,9 @@
11import { assertEquals } from "@std/assert";
22import { act, render, screen } from "@testing-library/react";
33import { userEvent } from "@testing-library/user-event";
4import type { FC, MouseEventHandler, ReactNode } from "react";
45import { test, vi } from "vitest";
5import { useMutate } from "../src/react.ts";
6import { createMutationButton, useMutate } from "../src/react.ts";
67import { createTestMutationClient, IterableStream } from "./share.ts";
78
89test("useMutate - global error and success handling", async () => {
......@@ -274,3 +275,46 @@ test("useMutate - local error and success handling", async () => {
274275 assertEquals(successMessages, []);
275276 assertEquals(errorMessages, []);
276277});
278
279test("MutationButton should allow args={null} to disable mutation runs", async () => {
280 const user = userEvent.setup({ delay: null });
281 const { client } = createTestMutationClient();
282 const mutate = vi.fn(async (value: number) => value + 1);
283
284 const mutTest = client.define({
285 mutate,
286 describe: "Test the action",
287 describeResult: "Tested the action",
288 optimistic: () => {},
289 });
290
291 const MutationButtonBase: FC<{
292 children?: ReactNode;
293 disabled?: boolean;
294 isPending: boolean;
295 onClick: MouseEventHandler<HTMLElement> | undefined;
296 }> = function MutationButtonBase({
297 children,
298 disabled,
299 isPending,
300 onClick,
301 }) {
302 return (
303 <button data-testid="a" disabled={disabled || isPending} onClick={onClick}>
304 {children}
305 </button>
306 );
307 };
308 const MutationButton = createMutationButton(MutationButtonBase);
309
310 render(
311 <MutationButton mutation={mutTest} args={null}>
312 button
313 </MutationButton>,
314 );
315
316 assertEquals((screen.getByTestId("a") as HTMLButtonElement).disabled, true);
317 await act(() => user.click(screen.getByTestId("a")));
318
319 assertEquals(mutate.mock.calls, []);
320});