diff --git a/src/mod.ts b/src/mod.ts index 6b40331c4f11580fdb0d76b4abcec041aee1f9fd..08eb4f7df483d22f76ceb161c8ad982cea1c6939 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -7,6 +7,8 @@ export { } from "./client.ts"; export type { MutationOptions, OptimisticContext } from "./mutation.ts"; export { + type AsyncCallbackOptions, + bindAsyncCallback, createMutationButton, type MutationButtonComponent, type MutationButtonProps, diff --git a/src/mutation.ts b/src/mutation.ts index f6291088e462f82347a5cb17b3aa37cc1d1ec2c8..9ca4e703ccb31fc9cf2bf440fa8e3dc8367ba887 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -942,7 +942,7 @@ export function formatFriendlyError( description: string | null, error: unknown, ) { - if (!description || !description[0]) return `Internal Error: ${errMessage(error)}`; + if (!description || !description[0]) return `Something went wrong: ${errMessage(error)}`; description = description[0].toLowerCase() + description.slice(1); return `Could not ${description}: ${errMessage(error)}`; } diff --git a/src/react.ts b/src/react.ts index 6ac2d17d46b51b6a5ad58421d4df8bae05c53608..75d8fe224185abec4c5db9062b0a709ae93edfbc 100644 --- a/src/react.ts +++ b/src/react.ts @@ -7,11 +7,13 @@ import { type ReactNode, useCallback, useEffect, + useRef, useState, } from "react"; import { jsx } from "react/jsx-runtime"; -import { formatFriendlyError } from "./mutation.ts"; -import type { Mutation, RunOptions } from "./types.ts"; +import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts"; +import { BlockingMutation, formatFriendlyError } from "./mutation.ts"; +import type { Json, Mutation, RunOptions } from "./types.ts"; /** * Subscribe to a mutation's status, as well as accessing a local `run` method. @@ -34,6 +36,52 @@ export function useMutate< return observer.binding; } +export interface AsyncCallbackOptions { + /** Phrased for the template `Could not ${describe}`. Omitted yields a generic error message. */ + describe?: string; + /** Success message shown through the client's global handler. Omitted shows nothing. */ + describeResult?: string | null; +} + +/** + * Binds {@link useAsyncCallback} to a client so ad-hoc async callbacks route + * their status and errors through it. A codebase re-exports the result once: + * `export const useAsyncCallback = bindAsyncCallback(mutations)`. + */ +export function bindAsyncCallback( + client: MutationClient, +): ( + callback: (...args: Args) => Promise, + options?: AsyncCallbackOptions, +) => UseMutateResult { + return function useAsyncCallback( + callback: (...args: Args) => Promise, + options?: AsyncCallbackOptions, + ): UseMutateResult { + const live = useRef({ callback, options }); + live.current = { callback, options }; + // A stable, unregistered mutation with no optimistic state; every field + // reads the ref so it tracks the latest callback and options. Args are + // never serialized here (no id, no auth replay), so the `Json[]` bound is + // cast away locally. Empty describe/describeResult fall through to the + // generic error message and no success toast, respectively. + const [mutation] = useState(() => + new BlockingMutation( + client as unknown as MutationClientFromConfig, + { + id: "", + mutate: (...args) => live.current.callback(...(args as unknown as Args)), + optimistic: () => {}, + describe: () => live.current.options?.describe ?? "", + describeResult: () => live.current.options?.describeResult ?? "", + refetchOnSuccess: false, + }, + ) as unknown as Mutation + ); + return useMutate(mutation); + }; +} + export type UseMutateResult = & UseMutateResultBase & ( diff --git a/test/asyncCallback.test.tsx b/test/asyncCallback.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..202948c5684fe15273c763ba04426255a7aef80e --- /dev/null +++ b/test/asyncCallback.test.tsx @@ -0,0 +1,212 @@ +import { assertEquals } from "@std/assert"; +import { act, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { FC, MouseEventHandler, ReactNode } from "react"; +import { test, vi } from "vitest"; +import { bindAsyncCallback, createMutationButton } from "../src/react.ts"; +import { createTestMutationClient, IterableStream } from "./share.ts"; + +type Snap = Partial<{ + isMutating: boolean; + isSuccess: boolean; + isError: boolean; + result: string | undefined; + errorMessage: string | undefined; +}>; + +test("useAsyncCallback - runs the callback and surfaces the result", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client, successMessages, errorMessages } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + const s = new IterableStream(); + + let last: Snap = {}; + function C() { + const { run, isMutating, isSuccess, result } = useAsyncCallback(async () => (await s.next()).value); + last = { isMutating, isSuccess, result }; + return ; + } + + render(); + vi.runAllTimers(); + + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(last.isMutating, true); + + await act(async () => { + s.push("done"); + vi.advanceTimersByTime(100); + }); + assertEquals(last.isSuccess, true); + assertEquals(last.result, "done"); + // describeResult defaults to null: no global success toast. + assertEquals(successMessages, []); + assertEquals(errorMessages, []); +}); + +test("useAsyncCallback - generic error message when no describe, suppressed globally while watched", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client, errorMessages } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + + let last: Snap = {}; + function C() { + const { run, isError, errorMessage } = useAsyncCallback(async () => { + throw new Error("boom"); + }); + last = { isError, errorMessage }; + return ; + } + + render(); + vi.runAllTimers(); + + await act(async () => { + await user.click(screen.getByTestId("a")); + vi.advanceTimersByTime(100); + }); + assertEquals(last.isError, true); + assertEquals(last.errorMessage, "Something went wrong: boom"); + // Component watches the error, so the global handler is suppressed. + assertEquals(errorMessages, []); +}); + +test("useAsyncCallback - global handler fires with describe when the error is unwatched", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client, errorMessages } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + + function C() { + const { run } = useAsyncCallback(async () => { + throw new Error("boom"); + }, { describe: "save the draft" }); + return ; + } + + render(); + vi.runAllTimers(); + + await act(async () => { + await user.click(screen.getByTestId("a")); + vi.advanceTimersByTime(100); + }); + assertEquals(errorMessages.map((e) => e.message), ["Could not save the draft: boom"]); +}); + +test("useAsyncCallback - tracks the latest describe across rerenders", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client, errorMessages } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + + function C({ label }: { label: string }) { + const { run } = useAsyncCallback(async () => { + throw new Error("boom"); + }, { describe: label }); + return ; + } + + const { rerender } = render(); + rerender(); + vi.runAllTimers(); + + await act(async () => { + await user.click(screen.getByTestId("a")); + vi.advanceTimersByTime(100); + }); + assertEquals(errorMessages.map((e) => e.message), ["Could not update the item: boom"]); +}); + +test("useAsyncCallback - always runs the latest callback closure", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + + let last: Snap = {}; + function C({ n }: { n: number }) { + const { run, result } = useAsyncCallback(async () => `v${n}`); + last = { result }; + return ; + } + + const { rerender } = render(); + rerender(); + vi.runAllTimers(); + + await act(async () => { + await user.click(screen.getByTestId("a")); + vi.advanceTimersByTime(100); + }); + // The mutation object was built on the n=1 render; the ref must pick up n=2. + assertEquals(last.result, "v2"); +}); + +test("useAsyncCallback - in-flight state survives a rerender", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + const { client } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + const s = new IterableStream(); + + let last: Snap = {}; + function C({ x }: { x: number }) { + const { run, isMutating, isSuccess, result } = useAsyncCallback(async () => (await s.next()).value); + last = { isMutating, isSuccess, result }; + return ; + } + + const { rerender } = render(); + vi.runAllTimers(); + + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(last.isMutating, true); + + // A rerender must not reset the observer and drop the running mutation. + rerender(); + assertEquals(last.isMutating, true); + + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(last.isSuccess, true); + assertEquals(last.result, "ok"); +}); + +test("useAsyncCallback - drives a mutation button directly", async () => { + const user = userEvent.setup({ delay: null }); + const { client } = createTestMutationClient(); + const useAsyncCallback = bindAsyncCallback(client); + const ran = vi.fn(async () => "ok"); + + const MutationButtonBase: FC<{ + children?: ReactNode; + disabled?: boolean; + isPending: boolean; + onClick: MouseEventHandler | undefined; + }> = function MutationButtonBase({ children, disabled, isPending, onClick }) { + return ( + + ); + }; + const MutationButton = createMutationButton(MutationButtonBase); + + function C() { + const state = useAsyncCallback(ran); + return ( + + go + + ); + } + + render(); + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(ran.mock.calls.length, 1); +});