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> {...@@ -150,7 +150,7 @@ class Observer<Args extends unknown[], Result> {
150 unsubscribe: (() => void) | null = null;150 unsubscribe: (() => void) | null = null;
151 currentKey: string | null = null;151 currentKey: string | null = null;
152 pendingTimer: Timer | null = null;152 pendingTimer: Timer | null = null;
153 debounced: boolean;153 debounced: boolean = false;
154154
155 constructor(setRerender: (fn: number) => void) {155 constructor(setRerender: (fn: number) => void) {
156 this.setRerender = setRerender;156 this.setRerender = setRerender;
...@@ -236,7 +236,7 @@ class Observer<Args extends unknown[], Result> {...@@ -236,7 +236,7 @@ class Observer<Args extends unknown[], Result> {
236 isSuccess: hasResult && !hasError,236 isSuccess: hasResult && !hasError,
237 isError: hasError,237 isError: hasError,
238 isOptimisticData: status === "waiting" || status === "mutating"238 isOptimisticData: status === "waiting" || status === "mutating"
239 || status === "refetching" || (hasError && status !== "idle"),239 || status === "refetching",
240 args: hasError || hasResult ? undefined : this.state.args,240 args: hasError || hasResult ? undefined : this.state.args,
241 });241 });
242242
...@@ -317,7 +317,7 @@ class Observer<Args extends unknown[], Result> {...@@ -317,7 +317,7 @@ class Observer<Args extends unknown[], Result> {
317 isSuccess: hasResult && !hasError,317 isSuccess: hasResult && !hasError,
318 isError: hasError,318 isError: hasError,
319 isOptimisticData: status === "waiting" || status === "mutating"319 isOptimisticData: status === "waiting" || status === "mutating"
320 || status === "refetching" || (hasError && status !== "idle"),320 || status === "refetching",
321 args: hasError || hasResult ? undefined : this.state.args,321 args: hasError || hasResult ? undefined : this.state.args,
322 });322 });
323 },323 },
...@@ -420,14 +420,17 @@ export interface MutationButtonProps<Args extends unknown[], Result> {...@@ -420,14 +420,17 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
420 | Mutation<Args, Result>420 | Mutation<Args, Result>
421 | UseMutateResult<Args, Result>;421 | UseMutateResult<Args, Result>;
422 /** Preventing default will interrupt the mutation */422 /** Preventing default will interrupt the mutation */
423 args: Args | ((e: MouseEvent) => Args | null);423 args: Args | null | ((e: MouseEvent) => Args | null);
424 /** Preventing default will interrupt the mutation */424 /** Preventing default will interrupt the mutation */
425 onClick?: (e: MouseEvent) => void;425 onClick?: (e: MouseEvent) => void;
426 disabled?: boolean;
426427
427 /** Omitting this will use the global error handler */428 /** Omitting this will use the global error handler */
428 onError?: (result: unknown) => void;429 onError?: (result: unknown) => void;
429 /** Omitting this will use the global success handler */430 /** 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
432 /** Global event handlers will still be called! */435 /** Global event handlers will still be called! */
433 onSettled?: (436 onSettled?: (
...@@ -471,6 +474,7 @@ type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;...@@ -471,6 +474,7 @@ type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
471type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<474type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
472 & Omit<Props, keyof MutationButtonProps<Args, Result>>475 & Omit<Props, keyof MutationButtonProps<Args, Result>>
473 & BaseButtonProps476 & BaseButtonProps
477 & { disabled?: boolean }
474>;478>;
475479
476function GenericMutationButton<480function GenericMutationButton<
...@@ -485,8 +489,10 @@ function GenericMutationButton<...@@ -485,8 +489,10 @@ function GenericMutationButton<
485 mutation,489 mutation,
486 args,490 args,
487 onClick,491 onClick,
492 disabled: disabledAttr,
488 onError,493 onError,
489 onSuccess,494 onSuccessUi,
495 onSuccessData,
490 onSettled,496 onSettled,
491 ...forwarded497 ...forwarded
492 } = props;498 } = props;
...@@ -494,22 +500,25 @@ function GenericMutationButton<...@@ -494,22 +500,25 @@ function GenericMutationButton<
494500
495 const localHook = useMutate("subscribe" in mutation ? mutation : null);501 const localHook = useMutate("subscribe" in mutation ? mutation : null);
496 const state = "subscribe" in mutation ? localHook : mutation;502 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
498 // NOTE: the JSR has trouble with JSX syntax for some reason.515 // NOTE: the JSR has trouble with JSX syntax for some reason.
499 return jsx(516 return jsx(
500 Component,517 Component,
501 {518 {
502 ...forwarded,519 ...forwarded,
503 onClick: useCallback((e: MouseEvent) => {520 disabled,
504 onClick?.(e);521 onClick: disabled ? undefined : handleClick,
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]),
513 isPending: state.isPending,522 isPending: state.isPending,
514 } satisfies Parameters<typeof Component>[0],523 } satisfies Parameters<typeof Component>[0],
515 );524 );
test/useMutate.test.tsx+45-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { assertEquals } from "@std/assert";1import { assertEquals } from "@std/assert";
2import { act, render, screen } from "@testing-library/react";2import { act, render, screen } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";3import { userEvent } from "@testing-library/user-event";
4import type { FC, MouseEventHandler, ReactNode } from "react";
4import { test, vi } from "vitest";5import { test, vi } from "vitest";
5import { useMutate } from "../src/react.ts";6import { createMutationButton, useMutate } from "../src/react.ts";
6import { createTestMutationClient, IterableStream } from "./share.ts";7import { createTestMutationClient, IterableStream } from "./share.ts";
78
8test("useMutate - global error and success handling", async () => {9test("useMutate - global error and success handling", async () => {
...@@ -274,3 +275,46 @@ test("useMutate - local error and success handling", async () => {...@@ -274,3 +275,46 @@ test("useMutate - local error and success handling", async () => {
274 assertEquals(successMessages, []);275 assertEquals(successMessages, []);
275 assertEquals(errorMessages, []);276 assertEquals(errorMessages, []);
276});277});
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});