From 1ea8a97377ab12b84a6d111aa16a911898376d94 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 29 Jan 2026 22:33:50 -0800 Subject: [PATCH] some more test cases --- jsr.json | 2 +- src/mutation.ts | 6 +- src/react.ts | 13 +- test/cases/runWithOptions.test.tsx | 61 +++++++ test/cases/setError.test.tsx | 260 +++++++++++++++++++++++++++++ test/useMutate.test.tsx | 103 ++---------- tsconfig.json | 5 +- vitest.config.ts | 5 + 8 files changed, 362 insertions(+), 93 deletions(-) create mode 100644 test/cases/runWithOptions.test.tsx create mode 100644 test/cases/setError.test.tsx diff --git a/jsr.json b/jsr.json index 7e15fdff1fec14af30899e479e0a1fc49589bb87..a80bfb0401303aaa51b135cb0d179b11c742a46d 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.12", + "version": "1.0.0-beta.13", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/src/mutation.ts b/src/mutation.ts index 614ea3dce73e3762ecbd2f418a1ba4088745aec7..949f2f5a10ba88c0bfe842d6a1e4121dbe7416e8 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -229,7 +229,7 @@ export class BlockingMutation< } /** Calls the mutation with custom handlers that can suppress global handlers. */ - runWithOptions(...array: [...Args, RunOptions]): Promise { + runWithOptions(...array: [...Args, RunOptions]): void { if (!this.#client.enabled) { throw new Error( "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", @@ -266,8 +266,6 @@ export class BlockingMutation< this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); } }); - - return promise; } /** Calls the mutation, treating the errors as promise rejection. */ @@ -415,6 +413,8 @@ export class BlockingMutation< } else { // Discard refetch callbacks if refetchOnSuccess is false channel.refetches = []; + // Notify listeners with success status and result before moving to next + this.#notify(channel, "mutating", result); this.#executeNext(key, channel); } resolve(result); diff --git a/src/react.ts b/src/react.ts index e4ed2e5b10ae97a2afdfe7dcf422fb8f8947f48b..c9bf18199e23286fc0e5e50c15babf12bd263201 100644 --- a/src/react.ts +++ b/src/react.ts @@ -45,7 +45,7 @@ export interface UseMutateResultBase { run: (...args: Args) => void; runWithOptions: ( ..._: [...args: Args, options: RunOptions] - ) => Promise; + ) => void; clear: () => void; setError: (error: unknown) => void; args: Args | undefined; @@ -89,6 +89,8 @@ export interface UseMutateIdle { result: undefined; error: undefined; errorMessage: undefined; + /** `true` when controls should be disabled */ + isDisabled: boolean; /** `true` when a `mutate` function is currently running. */ isMutating: boolean; /** `true` when a loading indicator should be shown. */ @@ -314,8 +316,8 @@ class Observer { ); } - // Delegate to the mutation's runWithOptions - mutation.runWithOptions(...args, options); + // Delegate to the mutation's runWithOptions and return the promise + return mutation.runWithOptions(...args, options); } binding: UseMutateResult = ((self: this) => ({ @@ -363,6 +365,11 @@ class Observer { self.watched.add("isMutating"); return self.state.isMutating; }, + // TODO: when auth drops this will be dependant on the auth status and isMutating + get isDisabled() { + self.watched.add("isMutating"); + return self.state.isMutating; + }, get isPending() { self.watched.add("isPending"); return self.state.isPending; diff --git a/test/cases/runWithOptions.test.tsx b/test/cases/runWithOptions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df47c81b80d554f1ade0c2b0ff6690d3a4311fa4 --- /dev/null +++ b/test/cases/runWithOptions.test.tsx @@ -0,0 +1,61 @@ +import { useMutate } from "@clo/react-mutation"; +import { assertEquals } from "@std/assert"; +import { act, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { test, vi } from "vitest"; +import { createTestMutationClient, IterableStream } from "../share.ts"; + +test("runWithOptions should allow react hook to do local handling", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + refetchOnSuccess: false, + }); + + let renders: Array<{ status: string; result: string | undefined }> = []; + function TestComponent() { + const { runWithOptions, status, result } = useMutate(mutTest); + renders.push({ status, result }); + return ( + + ); + } + + render(); + // initial state + assertEquals(renders, [{ status: "idle", result: undefined }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 - success + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ status: "mutating", result: undefined }]); + renders = []; + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ status: "success", result: "ok" }]); + renders = []; +}); diff --git a/test/cases/setError.test.tsx b/test/cases/setError.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..213586b1a4f587d6f0cbb7a968520ad6f3aa4a17 --- /dev/null +++ b/test/cases/setError.test.tsx @@ -0,0 +1,260 @@ +import { useMutate } from "@clo/react-mutation"; +import { assertEquals } from "@std/assert"; +import { act, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { test, vi } from "vitest"; +import { createTestMutationClient, IterableStream } from "../share.ts"; + +test("setError should manually set error state on the hook", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + + const mutTest = client.define({ + mutate: async () => { + return "success"; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + const manualError = new Error("Manual error"); + + let renders: Array<{ + status: string; + result: string | undefined; + error: unknown; + errorMessage: string | undefined; + isError: boolean; + isSuccess: boolean; + }> = []; + + function TestComponent() { + const { setError, status, result, error, errorMessage, isError, isSuccess } = useMutate( + mutTest, + ); + renders.push({ status, result, error, errorMessage, isError, isSuccess }); + return ( + + ); + } + + render(); + + // initial state - idle + assertEquals(renders, [ + { + status: "idle", + result: undefined, + error: undefined, + errorMessage: undefined, + isError: false, + isSuccess: false, + }, + ]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // manually set error using setError + await act(() => user.click(screen.getByTestId("set-error-btn"))); + assertEquals(renders, [ + { + status: "error", + result: undefined, + error: manualError, + errorMessage: "Manual error", + isError: true, + isSuccess: false, + }, + ]); + // setError should not trigger global error/success handlers + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; +}); + +test("setError should override success state", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + const customError = "Custom error message"; + + let renders: Array<{ + status: string; + result: string | undefined; + error: unknown; + isError: boolean; + isSuccess: boolean; + }> = []; + + function TestComponent() { + const { run, setError, status, result, error, isError, isSuccess } = useMutate(mutTest); + renders.push({ status, result, error, isError, isSuccess }); + return ( +
+ + +
+ ); + } + + render(); + + // initial state + assertEquals(renders, [ + { + status: "idle", + result: undefined, + error: undefined, + isError: false, + isSuccess: false, + }, + ]); + renders = []; + vi.runAllTimers(); + + // run mutation - should succeed + await act(() => user.click(screen.getByTestId("run-btn"))); + assertEquals(renders, [ + { + status: "mutating", + result: undefined, + error: undefined, + isError: false, + isSuccess: false, + }, + ]); + renders = []; + + await act(async () => { + s.push("success result"); + vi.advanceTimersByTime(100); + }); + + // verify success state + assertEquals(renders, [ + { + status: "success", + result: "success result", + error: undefined, + isError: false, + isSuccess: true, + }, + ]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + + // now manually set error - should override success state + await act(() => user.click(screen.getByTestId("set-error-btn"))); + assertEquals(renders, [ + { + status: "error", + result: undefined, + error: customError, + isError: true, + isSuccess: false, + }, + ]); + // setError should not trigger global error handler + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; +}); + +test("setError should work with different error types", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client } = createTestMutationClient(); + + const mutTest = client.define({ + mutate: async () => { + return "success"; + }, + describe: "Test the action", + describeResult: null, + optimistic: () => {}, + }); + + let lastErrorMessage: string | undefined; + + function TestComponent() { + const { setError, errorMessage } = useMutate(mutTest); + lastErrorMessage = errorMessage; + return ( +
+ + + +
+ ); + } + + render(); + vi.runAllTimers(); + + // Test string error + await act(() => user.click(screen.getByTestId("set-string-error"))); + assertEquals(lastErrorMessage, "String error"); + + // Test Error object + await act(() => user.click(screen.getByTestId("set-error-object"))); + assertEquals(lastErrorMessage, "Error object"); + + // Test number (should be converted to string) + await act(() => user.click(screen.getByTestId("set-number-error"))); + assertEquals(lastErrorMessage, "42"); +}); diff --git a/test/useMutate.test.tsx b/test/useMutate.test.tsx index e4a04c66385b3a6536043a8deca7d0f6b47cb392..dec12af6d80142abf5406c915904daf37c2373c9 100644 --- a/test/useMutate.test.tsx +++ b/test/useMutate.test.tsx @@ -211,6 +211,24 @@ test("useMutate - local error and success handling", async () => { }]); renders = []; + // clear state + vi.runAllTimers(); + await act(() => user.click(screen.getByTestId("b"))); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: false, + isOptimisticData: false, + isPending: false, + isSuccess: false, + result: undefined, + }]); + renders = []; + await act(() => user.click(screen.getByTestId("b"))); + assertEquals(renders, []); // nothing changed + renders = []; + // mutation 2 - failure await act(() => user.click(screen.getByTestId("a"))); assertEquals(renders, [{ @@ -256,88 +274,3 @@ test("useMutate - local error and success handling", async () => { assertEquals(successMessages, []); assertEquals(errorMessages, []); }); - -test("useMutate - global error and success handling", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const user = userEvent.setup({ delay: null }); - - const { client, successMessages, errorMessages } = createTestMutationClient(); - const s = new IterableStream(); - - const mutTest = client.define({ - mutate: async () => { - return (await s.next()).value; - }, - describe: "Test the action", - describeResult: "Tested the action", - optimistic: () => {}, - }); - - let renders: Array<{ isMutating: boolean; isPending: boolean }> = []; - function TestComponent() { - const { run, isMutating, isPending } = useMutate(mutTest); - renders.push({ isMutating, isPending }); - - return ( - <> - - - ); - } - - render(); - // initial state - assertEquals(renders, [{ isMutating: false, isPending: false }]); - assertEquals(successMessages, []); - assertEquals(errorMessages, []); - renders = []; - vi.runAllTimers(); - - // mutation 1 - await act(() => user.click(screen.getByTestId("a"))); - assertEquals(renders, [{ isMutating: true, isPending: false }]); - renders = []; - await act(() => vi.advanceTimersByTime(150)); - assertEquals(renders, []); - await act(() => vi.advanceTimersByTime(50)); - assertEquals(renders, [{ isMutating: true, isPending: true }]); - renders = []; - assertEquals(successMessages, []); - assertEquals(errorMessages, []); - await act(async () => { - s.push("ok"); - vi.advanceTimersByTime(100); - }); - assertEquals(successMessages, ["Tested the action"]); - assertEquals(errorMessages, []); - assertEquals(renders, [{ isMutating: false, isPending: false }]); - renders = []; - - // mutation 2 - await act(() => user.click(screen.getByTestId("a"))); - assertEquals(renders, [{ isMutating: true, isPending: false }]); - renders = []; - await act(() => vi.advanceTimersByTime(150)); - assertEquals(renders, []); - await act(() => vi.advanceTimersByTime(50)); - assertEquals(renders, [{ isMutating: true, isPending: true }]); - renders = []; - assertEquals(successMessages, ["Tested the action"]); - assertEquals(errorMessages, []); - const error1 = new Error("damn!"); - await act(async () => { - s.throw(error1); - vi.advanceTimersByTime(100); - }); - assertEquals(successMessages, ["Tested the action"]); - assertEquals(errorMessages, [ - { error: error1, message: "Could not test the action: damn!" }, - ]); -}); diff --git a/tsconfig.json b/tsconfig.json index 74acd2e711fc0a9ec870a4c9676ec435b461993e..8255db2c1cd274a470d818878f20731bf4055d1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,10 @@ "allowImportingTsExtensions": true, "jsx": "react-jsx", "verbatimModuleSyntax": true, - "types": ["react"] + "types": ["react"], + "paths": { + "@clo/react-mutation": ["./src/mod.ts"] + } }, "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules"] diff --git a/vitest.config.ts b/vitest.config.ts index 591c43c72dddfc9c4ca96dc4bc4702d3ca2dbe12..e1a3b824b878d033948441aae4f076f0ceb66bf3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@clo/react-mutation": import.meta.resolve("./src/mod.ts"), + }, + }, test: { globals: true, // Use happy-dom for React tests, fallback to node for others -- 2.54.0