diff --git a/jsr.json b/jsr.json index 0a42e466ab1ab27ce3b75d6b812e4846b2a6e4e8..a6b09d6d4ef9b2fbb0af41ede470cdac995fc5c7 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.14", + "version": "1.0.0-rc.1", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/package-lock.json b/package-lock.json index c4908b5c837a5a96d16d442d1f43be2e2a8082c3..4b7b0d411ecba2dd2672cea240565c50c07b4b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@std/assert": "npm:@jsr/std__assert@^1.0.17" }, "devDependencies": { + "@tanstack/react-query": "^5.90.20", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", @@ -1431,6 +1432,34 @@ "@jsr/std__internal": "^1.0.12" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index a6654d7d997d246d7ea24aeb22fec454102b9730..38c3e0dc3845dda9753c63d662224c60b506ed2c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@std/assert": "npm:@jsr/std__assert@^1.0.17" }, "devDependencies": { + "@tanstack/react-query": "^5.90.20", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", diff --git a/src/mutation.ts b/src/mutation.ts index 48745d618069b7842cb144c9da17017c4addf3c1..b6e8b6f8bebd9cd2304a773cea9bde8a052ae30a 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -63,6 +63,14 @@ export interface MutationOptions< * All pending promises resolve with the final result. */ debounceMs?: number; + /** + * When true, the first call executes immediately (leading edge), then + * subsequent rapid calls are debounced (trailing edge). After the debounce + * period ends, the next call executes immediately again. + * + * Requires `debounceMs` to be set. + */ + debounceImmediate?: boolean; /** * Called before and after optimistic updates to detect no-op mutations. * If the snapshots are equal (using deepEquals), the mutation is cancelled. @@ -100,6 +108,15 @@ interface PendingDebouncedState { onSuccess: Array<(result: Result) => void>; /** Initial snapshot before any debounced calls (for no-op detection) */ initialSnapshot?: unknown; + /** Whether the last call wanted global handlers to be called */ + shouldCallGlobalHandler: boolean; +} + +/** Wrapper for errors that includes the description captured before rollback */ +interface MutationError { + __mutationError: true; + error: unknown; + description: string; } interface Channel { @@ -113,6 +130,8 @@ interface Channel { // Debounce state (only used if debounce option is set) debounceTimer: ReturnType | null; pendingDebounced: PendingDebouncedState | null; + // Track when last debounced mutation executed (for debounceImmediate) + lastDebouncedExecutionTime: number | null; } interface Item { @@ -164,6 +183,7 @@ export class BlockingMutation< helpers: null, debounceTimer: null, pendingDebounced: null, + lastDebouncedExecutionTime: null, }; this.#channels.set(key, channel); } @@ -185,7 +205,7 @@ export class BlockingMutation< result: Result | null = null, error: unknown = null, ) { - const event: MutationEvent = { status, result, error }; + const event: MutationEvent = { status, result, error, debounced: this.#options.debounceMs !== undefined }; channel.listeners.forEach((cb) => cb(event)); } @@ -247,10 +267,11 @@ export class BlockingMutation< const args = array.slice() as Args; const { onSuccess, onSuccessDataOnly, onError, onSettled, onRestore } = args .pop() as RunOptions; - const suppressGlobalSuccess = onSuccess !== undefined; - const suppressGlobalError = onError !== undefined; + const suppressAll = this.#options.debounceMs !== undefined && !onSuccess && !onError; + const suppressGlobalSuccess = onSuccess !== undefined || suppressAll; + const suppressGlobalError = onError !== undefined || suppressAll; - const promise = this.#runWithOptions(args, { onRestore }); + const promise = this.#runWithOptions(args, onRestore, suppressAll); promise.then((result) => { // Call user handlers onSuccess?.(result); @@ -264,21 +285,27 @@ export class BlockingMutation< this.#client.reportSuccess(message); } } - }).catch((error) => { - // Call user handlers + }).catch((caught: unknown) => { + // Extract error and description if this is a wrapped mutation error + const isMutationError = (caught as MutationError)?.__mutationError === true; + const error = isMutationError ? (caught as MutationError).error : caught; + const description = isMutationError ? (caught as MutationError).description : this.describe(...args); + + // Call user handlers with the unwrapped error onError?.(error); onSettled?.({ status: "error", error }); // Call global handler unless suppressed if (!suppressGlobalError) { - this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); + this.#client.reportError(formatFriendlyError(description, error), error); } }); } #runWithOptions( args: Args, - { onRestore: userOnRestore }: Pick, "onRestore">, + userOnRestore: RunOptions["onRestore"], + suppressGlobalHandlers: boolean, ): Promise { if (!this.#client.enabled) { throw new Error( @@ -290,7 +317,20 @@ export class BlockingMutation< // Check if debouncing is enabled if (this.#options.debounceMs !== undefined) { - return this.#runDebouncedAndReturn(args, key, channel, userOnRestore, true); + // Check if we should execute immediately (leading edge) + const shouldExecuteImmediate = this.#options.debounceImmediate && ( + channel.lastDebouncedExecutionTime === null + || Date.now() - channel.lastDebouncedExecutionTime >= this.#options.debounceMs + ); + + return this.#runDebouncedAndReturn( + args, + key, + channel, + userOnRestore, + !!shouldExecuteImmediate, + suppressGlobalHandlers, + ); } // Take snapshot before optimistic update (if snapshot function defined) @@ -450,6 +490,10 @@ export class BlockingMutation< } resolve(result); }, (error) => { + // Capture description BEFORE rollback so it sees optimistic state + const description = this.describe(...args); + const wrappedError: MutationError = { __mutationError: true, error, description }; + // if an error happens, then every rollback is called in reverse order let next; while (next = channel.rollbacks.pop()) next(); @@ -457,7 +501,7 @@ export class BlockingMutation< // Cancel all remaining items in the channel const remainingItems = channel.queue.splice(0); remainingItems.forEach((queuedItem) => { - queuedItem.reject(error); + queuedItem.reject(wrappedError); }); // Notify listeners of the error @@ -480,7 +524,7 @@ export class BlockingMutation< this.#setIdle(key, channel); }); - reject(error); + reject(wrappedError); }); } @@ -488,8 +532,9 @@ export class BlockingMutation< args: Args, key: string, channel: Channel, - userOnRestore?: () => void, - fromRunWithOptions = false, + userOnRestore: (() => void) | undefined, + shouldExecuteImmediate: boolean, + shouldCallGlobalHandler: boolean, ): Promise { // Capture initial snapshot before first debounced call const isFirstDebouncedCall = channel.pendingDebounced === null; @@ -622,6 +667,7 @@ export class BlockingMutation< pending: [{ resolve, reject }], onSuccess, initialSnapshot: isFirstDebouncedCall ? initialSnapshot : undefined, + shouldCallGlobalHandler, }; // Set status to waiting @@ -633,6 +679,7 @@ export class BlockingMutation< channel.pendingDebounced.rollbackCount = rollbacks; channel.pendingDebounced.pending.push({ resolve, reject }); channel.pendingDebounced.onSuccess = onSuccess; + channel.pendingDebounced.shouldCallGlobalHandler = shouldCallGlobalHandler; // Keep the initial snapshot from the first call // Status stays "waiting" } @@ -642,10 +689,11 @@ export class BlockingMutation< clearTimeout(channel.debounceTimer); } - // Start new timer + // Start new timer (0ms for immediate execution, debounceMs otherwise) + const delay = shouldExecuteImmediate ? 0 : this.#options.debounceMs!; channel.debounceTimer = setTimeout(() => { this.#enqueueDebouncedCall(key, channel); - }, this.#options.debounceMs); + }, delay); return promise; } @@ -681,11 +729,13 @@ export class BlockingMutation< return; } - const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced; + const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; channel.pendingDebounced = null; - // Check if there are any listeners at time of enqueue - const hasListeners = channel.listeners.size > 0; + // Track execution time for debounceImmediate + if (this.#options.debounceImmediate) { + channel.lastDebouncedExecutionTime = Date.now(); + } // Create wrapper resolve/reject that resolves ALL pending promises const { @@ -700,27 +750,27 @@ export class BlockingMutation< // Resolve all pending promises pending.forEach((p) => p.resolve(result)); - // Check if there are any listeners at execution time - const hasListeners = channel.listeners.size > 0; - if (!hasListeners) { + // Call global handler if needed (based on whether component is watching success) + if (shouldCallGlobalHandler) { const message = this.describeResult(args, result); if (message && this.#client.reportSuccess) { this.#client.reportSuccess(message); } } }, - (error) => { - // Reject all pending promises + (caught) => { + // Extract error and description if this is a wrapped mutation error + const isMutationError = (caught as MutationError)?.__mutationError === true; + const error = isMutationError ? (caught as MutationError).error : caught; + const description = isMutationError ? (caught as MutationError).description : this.describe(...args); + + // Reject all pending promises with unwrapped error pending.forEach((p) => p.reject(error)); - // Check if there are any listeners at execution time - const hasListeners = channel.listeners.size > 0; - if (!hasListeners) { + // Call global handler if needed (based on whether component is watching errors) + if (shouldCallGlobalHandler) { this.#client.reportError( - formatFriendlyError( - this.describe(...args), - error, - ), + formatFriendlyError(description, error), error, ); } diff --git a/src/react.ts b/src/react.ts index c324a2196c7a54b88484cfc2fabe1b8c0eea2b43..6462c991275cca0158510e7c2e9bb6f3c4e3630d 100644 --- a/src/react.ts +++ b/src/react.ts @@ -150,6 +150,7 @@ class Observer { unsubscribe: (() => void) | null = null; currentKey: string | null = null; pendingTimer: Timer | null = null; + debounced: boolean; constructor(setRerender: (fn: number) => void) { this.setRerender = setRerender; @@ -176,6 +177,7 @@ class Observer { this.unsubscribe = null; this.currentKey = null; this.state = initialState(); + this.debounced = false; } resetPending() { @@ -203,7 +205,9 @@ class Observer { this.unsubscribe?.(); this.unsubscribe = mutation.subscribe( mutation.key(args), - ({ status, error, result }) => { + ({ status, error, result, debounced }) => { + this.debounced = debounced; + if (status === "idle") { this.setState({ isMutating: false, @@ -232,10 +236,11 @@ class Observer { isSuccess: hasResult && !hasError, isError: hasError, isOptimisticData: status === "waiting" || status === "mutating" - || status === "refetching", + || status === "refetching" || (hasError && status !== "idle"), args: hasError || hasResult ? undefined : this.state.args, }); - if (!this.state.isPending && this.state.isMutating) { + + if (!this.state.isPending && this.state.isMutating && !debounced) { this.pendingTimer = setTimeout(() => { this.pendingTimer = null; this.setState({ isPending: true }); @@ -258,6 +263,9 @@ class Observer { { onSuccess: watchesSuccess ? () => {} : undefined, onError: watchesError ? () => {} : undefined, + // For debounced mutations, suppress global handlers in runWithOptions + // The debounce logic (#enqueueDebouncedCall) will call them once if needed + // But only if the component isn't watching success/error } satisfies RunOptions, ); return promise; @@ -309,7 +317,7 @@ class Observer { isSuccess: hasResult && !hasError, isError: hasError, isOptimisticData: status === "waiting" || status === "mutating" - || status === "refetching", + || status === "refetching" || (hasError && status !== "idle"), args: hasError || hasResult ? undefined : this.state.args, }); }, @@ -368,7 +376,7 @@ class Observer { // TODO: when auth drops this will be dependant on the auth status and isMutating get isDisabled() { self.watched.add("isMutating"); - return !self.mutation || self.state.isMutating; + return !self.mutation || (self.state.isMutating && !self.debounced); }, get isPending() { self.watched.add("isPending"); diff --git a/src/types.ts b/src/types.ts index 572ee00743f0edc62166717f4f99b8db95565a80..5126cc54c051569b9b5cdd53132def816d7d400a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,10 +33,13 @@ export interface RunOptions { ) => void; /** Called when optimistic state is being restored/rolled back */ onRestore?: () => void; + /** @internal Suppresses global handlers for debounced mutations */ + __suppressGlobalForDebounce?: boolean; } export interface MutationEvent { status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; result: Result | null; error: unknown; + debounced: boolean; } diff --git a/test/cases/runWithOptions.test.tsx b/test/cases/runWithOptions.test.tsx deleted file mode 100644 index 74c8de639e2a683149be993394834d64b51fdbc9..0000000000000000000000000000000000000000 --- a/test/cases/runWithOptions.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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; - }, - optimistic: ({ onSuccess }) => { - onSuccess(() => {}); - }, - refetchOnSuccess: false, - describe: "Test the action", - describeResult: "Tested the action", - }); - - 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 = []; - await act(async () => { - vi.advanceTimersByTime(10000); - }); - assertEquals(renders, []); - renders = []; -}); diff --git a/test/cases/setError.test.tsx b/test/cases/setError.test.tsx deleted file mode 100644 index 213586b1a4f587d6f0cbb7a968520ad6f3aa4a17..0000000000000000000000000000000000000000 --- a/test/cases/setError.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -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/cases/snapshot.test.tsx b/test/cases/snapshot.test.tsx deleted file mode 100644 index a71627aabc99e2020f33e0b0f1f81d94101a9443..0000000000000000000000000000000000000000 --- a/test/cases/snapshot.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useMutate } from "@clo/react-mutation"; -import { assert, 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("snapshot should skip no-op mutation", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const user = userEvent.setup({ delay: null }); - - const { client, successMessages, errorMessages } = createTestMutationClient(); - const s = new IterableStream(); - - let state = { value: "initial" }; - let failed = false; - const mutUpdate = client.define({ - mutate: async (newValue: string) => { - failed = true; - }, - optimistic: ({ args: [newValue] }) => { - state.value = newValue; - }, - snapshot: () => state.value, - describe: "Update value", - describeResult: "Updated value", - }); - - let renders: Array<{ status: string; isOptimisticData: boolean }> = []; - function TestComponent() { - const { run, status, isOptimisticData } = useMutate(mutUpdate); - renders.push({ status, isOptimisticData }); - return ( - - ); - } - - render(); - assertEquals(renders, [{ status: "idle", isOptimisticData: false }]); - renders = []; - - // Click to run mutation with same value (no-op) - await act(() => user.click(screen.getByTestId("btn"))); - vi.runAllTimers(); - - // Should skip the mutation and return to idle without calling API - assert(!failed); - assertEquals(state.value, "initial"); - assertEquals(renders, []); // nothing changed - assertEquals(successMessages, []); // nothing happened - assertEquals(errorMessages, []); -}); - -test("snapshot should allow mutation when value changes", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const user = userEvent.setup({ delay: null }); - - const { client, successMessages, errorMessages } = createTestMutationClient(); - const s = new IterableStream(); - - let state = { value: "initial" }; - - const mutUpdate = client.define({ - mutate: async (newValue: string) => { - return (await s.next()).value; - }, - optimistic: ({ args: [newValue] }) => { - state.value = newValue; - }, - snapshot: () => state.value, - describe: "Update value", - describeResult: "Updated value", - }); - - let renders: Array<{ status: string; isOptimisticData: boolean }> = []; - function TestComponent() { - const { run, status, isOptimisticData } = useMutate(mutUpdate); - renders.push({ status, isOptimisticData }); - return ( - - ); - } - - render(); - renders = []; - - // Click to run mutation with different value - await act(() => user.click(screen.getByTestId("btn"))); - assertEquals(state.value, "changed"); // Optimistic applied - assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]); - renders = []; - - // Complete the mutation - await act(async () => { - s.push("ok"); - vi.advanceTimersByTime(100); - }); - - assertEquals(renders, [{ status: "success", isOptimisticData: false }]); - assertEquals(successMessages, ["Updated value"]); - assertEquals(errorMessages, []); -}); - -test("debounced snapshot should skip when final state equals initial", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const user = userEvent.setup({ delay: null }); - - const { client, successMessages, errorMessages } = createTestMutationClient(); - const s = new IterableStream(); - - let state = { value: "initial" }; - - const mutations: string[] = []; - const mutUpdate = client.define({ - mutate: async (newValue: string) => { - mutations.push(newValue); - }, - optimistic: ({ args: [newValue] }) => { - state.value = newValue; - }, - snapshot: () => state.value, - debounceMs: 500, - refetchOnSuccess: false, - describe: "Update value", - describeResult: "Updated value", - }); - - let renders: Array<{ status: string }> = []; - function TestComponent() { - const { run, status } = useMutate(mutUpdate); - renders.push({ status }); - return ( - <> - - - - ); - } - - render(); - assertEquals(renders, [{ status: "idle" }]); - renders = []; - - // First call - change value - await act(() => user.click(screen.getByTestId("a"))); - assertEquals(state.value, "changed"); - assertEquals(renders, []); // Debounced mutations don't show mutating, no update - assertEquals(mutations, []); - renders = []; - - // Second call - revert to initial (no-op overall) - await act(() => user.click(screen.getByTestId("b"))); - assertEquals(state.value, "initial"); // Should be rolled back to initial - assertEquals(renders, []); // Still idle - assertEquals(successMessages, []); - assertEquals(errorMessages, []); - assertEquals(mutations, []); - - await act(() => vi.runAllTimers()); - assertEquals(successMessages, []); - assertEquals(errorMessages, []); - assertEquals(mutations, []); -}); - -test("debounced snapshot should mutate when final differs from initial", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const user = userEvent.setup({ delay: null }); - - const { client, successMessages, errorMessages } = createTestMutationClient(); - const s = new IterableStream(); - - let state = { value: "initial" }; - - const mutUpdate = client.define({ - mutate: async (newValue: string) => { - return (await s.next()).value; - }, - optimistic: ({ args: [newValue] }) => { - state.value = newValue; - }, - snapshot: () => state.value, - debounceMs: 500, - refetchOnSuccess: false, - describe: "Update value", - describeResult: "Updated value", - }); - - let renders: Array<{ status: string; result: string | undefined }> = []; - function TestComponent() { - const { run, status, result } = useMutate(mutUpdate); - renders.push({ status, result }); - return ( - <> - - - - ); - } - - render(); - renders = []; - - // First call - await act(() => user.click(screen.getByTestId("a"))); - assertEquals(state.value, "temp"); - renders = []; - - // Second call - different from initial - await act(() => user.click(screen.getByTestId("b"))); - assertEquals(state.value, "final"); - renders = []; - - // Wait for debounce - await act(async () => { - vi.advanceTimersByTime(500); - }); - assertEquals(renders, [{ status: "mutating", result: undefined }]); - renders = []; - - // Complete mutation - await act(async () => { - s.push("ok"); - vi.advanceTimersByTime(100); - }); - assertEquals(renders, [{ status: "success", result: "ok" }]); - assertEquals(successMessages, []); -}); diff --git a/test/debounce.test.tsx b/test/debounce.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8946e2bd6c8940ebae201d59643c1af8e52355b --- /dev/null +++ b/test/debounce.test.tsx @@ -0,0 +1,274 @@ +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("debounceMs: waits before first call", 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: () => {}, + debounceMs: 500, + }); + + let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest); + renders.push({ isMutating, isPending, isOptimisticData, isDisabled }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(200)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(300)); + assertEquals(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, isDisabled: false, isOptimisticData: false }]); + renders = []; + successMessages.splice(0, successMessages.length); + + vi.advanceTimersByTime(1000); + + // mutation 2 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(200)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, []); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + const error1 = new Error("damn!"); + await act(async () => { + s.throw(error1); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, []); + assertEquals(errorMessages, [ + { error: error1, message: "Could not test the action: damn!" }, + ]); +}); + +test("debounceMs: batch multiple calls together", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutations: number[] = []; + const mutTest = client.define({ + mutate: async (k: number) => { + mutations.push(k); + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + debounceMs: 500, + }); + + let i = 0; + + let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest); + renders.push({ isMutating, isPending, isOptimisticData, isDisabled }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true, isDisabled: false }]); + renders = []; + await act(() => user.click(screen.getByTestId("a"))); + await act(() => user.click(screen.getByTestId("a"))); + await act(() => user.click(screen.getByTestId("a"))); + await act(() => user.click(screen.getByTestId("a"))); + await act(() => vi.advanceTimersByTime(200)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, []); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + assertEquals(mutations, [4]); + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]); + renders = []; + successMessages.splice(0, successMessages.length); + + vi.advanceTimersByTime(1000); + assertEquals(renders, []); + renders = []; +}); + +test.todo("debounceImmediate runs the first one right away", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutations: number[] = []; + const mutTest = client.define({ + mutate: async (k: number) => { + mutations.push(k); + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + debounceMs: 500, + debounceImmediate: true, + }); + + let i = 0; + + let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending, isOptimisticData, isDisabled } = useMutate(mutTest); + renders.push({ isMutating, isPending, isOptimisticData, isDisabled }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false, isDisabled: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true, isDisabled: false }]); + renders = []; + await act(async () => { + assertEquals(mutations, [0]); + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]); + renders = []; + + // mutation 2 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]); + renders = []; + + await act(() => user.click(screen.getByTestId("a"))); + await act(() => user.click(screen.getByTestId("a"))); + await act(() => user.click(screen.getByTestId("a"))); + await act(() => vi.advanceTimersByTime(200)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, [{ isMutating: true, isPending: false, isDisabled: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(300)); + assertEquals(renders, []); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + assertEquals(mutations, [1]); + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ isMutating: false, isPending: false, isDisabled: false, isOptimisticData: false }]); + renders = []; + successMessages.splice(0, successMessages.length); + + vi.advanceTimersByTime(1000); + assertEquals(renders, []); + renders = []; +}); diff --git a/test/mutations.test.ts b/test/mutations.test.ts deleted file mode 100644 index 9953a339a9e96bef3d95010234561507f6bbdd50..0000000000000000000000000000000000000000 --- a/test/mutations.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { delay } from "@clo/lib/async.ts"; -import { MutationClient } from "@clo/react-mutation"; -import { assertEquals } from "@std/assert"; -import { test, vi } from "vitest"; -import { createTestMutationClient } from "./share.ts"; - -test("run executes callbacks in correct order", async () => { - vi.useFakeTimers(); - const { client, errorMessages, successMessages } = createTestMutationClient(); - const calls: string[] = []; - const mutTest = client.define({ - mutate: async () => { - calls.push("mutate"); - await delay(100); - return "success"; - }, - describe: () => { - calls.push("describe"); - return "Test the action"; - }, - describeResult: () => { - calls.push("describeResult"); - return "Tested the action"; - }, - optimistic: ({ onRefetch, onRestore, onSuccess }) => { - onRefetch(async () => void calls.push("refetch")); - onSuccess(async () => void calls.push("success")); - onRestore(async () => void calls.push("restore")); - calls.push("optimistic"); - }, - }); - mutTest.run(); - await vi.advanceTimersByTimeAsync(50); - assertEquals(calls, [ - "optimistic", - "mutate", - ]); - await vi.advanceTimersByTimeAsync(100); - assertEquals(calls, [ - "optimistic", - "mutate", - "success", - "refetch", - "describeResult", - ]); - assertEquals(errorMessages, []); - assertEquals(successMessages, ["Tested the action"]); -}); diff --git a/test/optimistic.test.tsx b/test/optimistic.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea8a7be8587f58fd894938331fbfb7720ae17f99 --- /dev/null +++ b/test/optimistic.test.tsx @@ -0,0 +1,117 @@ +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.each([ + [true], + [false], +])("useMutate - isOptimisticData stays true while refetching (refetchOnSuccess = %s)", async (refetchOnSuccess) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + + const streamResults = new IterableStream(); + const streamRefreshes = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await streamResults.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: ({ onRefetch }) => { + onRefetch(async () => { + await streamRefreshes.next(); + }); + }, + refetchOnSuccess, + }); + + let renders: Array<{ isMutating: boolean; isPending: boolean; isOptimisticData: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending, isOptimisticData } = useMutate(mutTest); + renders.push({ isMutating, isPending, isOptimisticData }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true, isOptimisticData: true }]); + renders = []; + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + streamResults.push("ok"); + vi.advanceTimersByTime(150); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: refetchOnSuccess }]); + renders = []; + if (refetchOnSuccess) { + await act(async () => { + streamRefreshes.push("refetch"); + vi.advanceTimersByTime(100); + }); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]); + renders = []; + } + successMessages.splice(0, successMessages.length); + + // mutation 2 - error + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false, isOptimisticData: true }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true, isOptimisticData: true }]); + renders = []; + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + const error1 = new Error("damn!"); + await act(async () => { + streamResults.throw(error1); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, []); + assertEquals(errorMessages, [ + { error: error1, message: "Could not test the action: damn!" }, + ]); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: true }]); + renders = []; + await act(async () => { + streamRefreshes.push("refetch"); + vi.advanceTimersByTime(100); + }); + assertEquals(renders, [{ isMutating: false, isPending: false, isOptimisticData: false }]); +}); diff --git a/test/ordering.test.ts b/test/ordering.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ae1f4351c1f0292d64a3a96b202bebf64af1661 --- /dev/null +++ b/test/ordering.test.ts @@ -0,0 +1,426 @@ +import { delay } from "@clo/lib/async.ts"; +import { assertEquals } from "@std/assert"; +import { test, vi } from "vitest"; +import { createTestMutationClient } from "./share.ts"; + +test.each([ + false, + true, +])("run executes callbacks in correct order (refetchOnSuccess=%s)", async (refetchOnSuccess) => { + vi.useFakeTimers(); + const { client, errorMessages, successMessages } = createTestMutationClient(); + const calls: string[] = []; + const mutTest = client.define({ + mutate: async () => { + calls.push("mutate"); + await delay(100); + return "success"; + }, + describe: () => { + calls.push("describe"); + return "Test the action"; + }, + describeResult: () => { + calls.push("describeResult"); + return "Tested the action"; + }, + optimistic: ({ onRefetch, onRestore, onSuccess }) => { + onRefetch(async () => void calls.push("refetch")); + onSuccess(async () => void calls.push("success")); + onRestore(async () => void calls.push("restore")); + calls.push("optimistic"); + }, + refetchOnSuccess, + }); + mutTest.run(); + await vi.advanceTimersByTimeAsync(50); + assertEquals(calls, [ + "optimistic", + "mutate", + ]); + await vi.advanceTimersByTimeAsync(100); + assertEquals( + calls, + [ + "optimistic", + "mutate", + "success", + refetchOnSuccess && "refetch", + "describeResult", + ].filter(Boolean), + ); + assertEquals(errorMessages, []); + assertEquals(successMessages, ["Tested the action"]); +}); + +test("error case: describe is called before rollback", async () => { + vi.useFakeTimers(); + const { client, errorMessages, successMessages } = createTestMutationClient(); + const calls: string[] = []; + let optimisticState = false; + + const mutTest = client.define({ + mutate: async () => { + calls.push("mutate"); + await delay(100); + throw new Error("API failed"); + }, + describe: () => { + calls.push("describe"); + // This simulates reading optimistic state (e.g., get(...).isFollowing) + calls.push(`describe-state:${optimisticState}`); + return "follow user"; + }, + describeResult: () => { + calls.push("describeResult"); + return "Followed user"; + }, + optimistic: ({ onRefetch, onRestore, onSuccess }) => { + optimisticState = true; + onRefetch(async () => void calls.push("refetch")); + onSuccess(async () => void calls.push("success")); + onRestore(async () => { + calls.push("restore"); + optimisticState = false; + }); + calls.push("optimistic"); + }, + }); + + mutTest.run(); + await vi.advanceTimersByTimeAsync(50); + assertEquals(calls, [ + "optimistic", + "mutate", + ]); + + await vi.advanceTimersByTimeAsync(100); + // CRITICAL: describe must be called BEFORE restore + // So the order should be: optimistic -> mutate -> describe -> restore -> refetch + const describeIndex = calls.indexOf("describe"); + const restoreIndex = calls.indexOf("restore"); + + assertEquals( + describeIndex < restoreIndex, + true, + `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${calls.join(", ")}`, + ); + + // Verify describe was called with optimistic state still active + assertEquals(calls.includes("describe-state:true"), true, "describe should see optimistic state"); + + assertEquals(successMessages, []); + assertEquals(errorMessages.length, 1); + assertEquals(errorMessages[0].message, "Could not follow user: API failed"); +}); + +test("error case with refetchOnSuccess=false still refetches", async () => { + vi.useFakeTimers(); + const { client, errorMessages } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async () => { + calls.push("mutate"); + await delay(100); + throw new Error("Failed"); + }, + describe: () => { + calls.push("describe"); + return "test action"; + }, + describeResult: null, + optimistic: ({ onRefetch, onRestore }) => { + onRefetch(async () => void calls.push("refetch")); + onRestore(async () => void calls.push("restore")); + calls.push("optimistic"); + }, + refetchOnSuccess: false, + }); + + mutTest.run(); + await vi.advanceTimersByTimeAsync(200); + + // Even with refetchOnSuccess=false, errors should still trigger refetch + assertEquals(calls.includes("refetch"), true, "refetch should be called on error"); + assertEquals(calls.includes("restore"), true, "restore should be called on error"); + assertEquals(errorMessages.length, 1); +}); + +test("multiple mutations in sequence: ordering preserved", async () => { + vi.useFakeTimers(); + const { client, successMessages } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async (id: number) => { + calls.push(`mutate-${id}`); + await delay(100); + return `result-${id}`; + }, + describe: () => { + calls.push("describe"); + return "test action"; + }, + describeResult: (ctx) => { + calls.push(`describeResult-${ctx.args[0]}`); + return `Completed ${ctx.args[0]}`; + }, + optimistic: ({ args, onSuccess, onRestore }) => { + onSuccess(async () => void calls.push(`success-${args[0]}`)); + onRestore(async () => void calls.push(`restore-${args[0]}`)); + calls.push(`optimistic-${args[0]}`); + }, + refetchOnSuccess: false, + }); + + mutTest.run(1); + mutTest.run(2); + mutTest.run(3); + + await vi.advanceTimersByTimeAsync(50); + // Optimistic updates apply immediately, but mutate-1 starts right after optimistic-1 + assertEquals(calls, [ + "optimistic-1", + "mutate-1", + "optimistic-2", + "optimistic-3", + ]); + + await vi.advanceTimersByTimeAsync(100); + // describeResult is called in a promise handler, so it happens after mutate-2 starts + assertEquals(calls, [ + "optimistic-1", + "mutate-1", + "optimistic-2", + "optimistic-3", + "success-1", + "mutate-2", + "describeResult-1", + ]); + + await vi.advanceTimersByTimeAsync(100); + assertEquals(calls, [ + "optimistic-1", + "mutate-1", + "optimistic-2", + "optimistic-3", + "success-1", + "mutate-2", + "describeResult-1", + "success-2", + "mutate-3", + "describeResult-2", + ]); + + await vi.advanceTimersByTimeAsync(100); + assertEquals(successMessages, ["Completed 1", "Completed 2", "Completed 3"]); +}); + +test("error in second mutation: first stays applied, second rolls back", async () => { + vi.useFakeTimers(); + const { client, errorMessages, successMessages } = createTestMutationClient(); + const calls: string[] = []; + let state = 0; + + const mutTest = client.define({ + mutate: async (id: number) => { + calls.push(`mutate-${id}`); + await delay(100); + if (id === 2) throw new Error("Second failed"); + return `result-${id}`; + }, + describe: (ctx) => { + calls.push(`describe-${ctx.args[0]}`); + calls.push(`describe-${ctx.args[0]}-state:${state}`); + return `action ${ctx.args[0]}`; + }, + describeResult: (ctx) => { + calls.push(`describeResult-${ctx.args[0]}`); + return `Completed ${ctx.args[0]}`; + }, + optimistic: ({ args, onSuccess, onRestore }) => { + state += 1; + onSuccess(async () => void calls.push(`success-${args[0]}`)); + onRestore(async () => { + calls.push(`restore-${args[0]}`); + state -= 1; + }); + calls.push(`optimistic-${args[0]}`); + }, + refetchOnSuccess: false, + }); + + mutTest.run(1); + mutTest.run(2); + + await vi.advanceTimersByTimeAsync(250); + + // First mutation succeeds, second fails + // describe-2 should see state=2 (both optimistic updates applied) + // Then restore-2 rolls back only the second mutation + assertEquals(calls.includes("describe-2-state:2"), true, "describe for error should see optimistic state"); + assertEquals(calls.includes("success-1"), true); + assertEquals(calls.includes("restore-2"), true); + assertEquals(successMessages, ["Completed 1"]); + assertEquals(errorMessages.length, 1); + assertEquals(errorMessages[0].message, "Could not action 2: Second failed"); +}); + +test("onSuccess callback ordering relative to refetch", async () => { + vi.useFakeTimers(); + const { client } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async () => { + calls.push("mutate"); + await delay(100); + return "success"; + }, + describe: () => "test", + describeResult: () => "tested", + optimistic: ({ onRefetch, onSuccess }) => { + onRefetch(async () => { + calls.push("refetch-start"); + await delay(50); + calls.push("refetch-end"); + }); + onSuccess(async () => { + calls.push("onSuccess-start"); + await delay(30); + calls.push("onSuccess-end"); + }); + calls.push("optimistic"); + }, + refetchOnSuccess: true, + }); + + mutTest.run(); + await vi.advanceTimersByTimeAsync(100); + + // onSuccess should complete before refetch starts + const onSuccessEndIndex = calls.indexOf("onSuccess-end"); + const refetchStartIndex = calls.indexOf("refetch-start"); + + assertEquals( + onSuccessEndIndex < refetchStartIndex, + true, + `onSuccess must complete before refetch starts. Order: ${calls.join(", ")}`, + ); + + await vi.advanceTimersByTimeAsync(100); + assertEquals(calls.includes("refetch-end"), true); +}); + +test("runWithOptions callbacks: onError called before global handler", async () => { + vi.useFakeTimers(); + const { client, errorMessages } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async () => { + await delay(100); + throw new Error("Failed"); + }, + describe: () => { + calls.push("describe"); + return "test"; + }, + describeResult: () => "tested", + optimistic: ({ onRestore }) => { + onRestore(() => calls.push("restore")); + calls.push("optimistic"); + }, + }); + + mutTest.runWithOptions({ + onError: () => { + calls.push("onError"); + }, + onSettled: () => { + calls.push("onSettled"); + }, + }); + + await vi.advanceTimersByTimeAsync(200); + + // onError suppresses global error handler + assertEquals(errorMessages, [], "onError should suppress global error handler"); + assertEquals(calls.includes("onError"), true); + assertEquals(calls.includes("onSettled"), true); + + // onError should be called before onSettled + const onErrorIndex = calls.indexOf("onError"); + const onSettledIndex = calls.indexOf("onSettled"); + assertEquals(onErrorIndex < onSettledIndex, true); +}); + +test("runWithOptions callbacks: onSuccess called before global handler", async () => { + vi.useFakeTimers(); + const { client, successMessages } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async () => { + await delay(100); + return "result"; + }, + describe: () => "test", + describeResult: () => { + calls.push("describeResult"); + return "tested"; + }, + optimistic: () => { + calls.push("optimistic"); + }, + refetchOnSuccess: false, + }); + + mutTest.runWithOptions({ + onSuccess: () => { + calls.push("onSuccess"); + }, + onSettled: () => { + calls.push("onSettled"); + }, + }); + + await vi.advanceTimersByTimeAsync(150); + + // onSuccess suppresses global success handler (describeResult won't add to successMessages) + assertEquals(successMessages, [], "onSuccess should suppress global success handler"); + assertEquals(calls.includes("onSuccess"), true); + assertEquals(calls.includes("onSettled"), true); + + // Order should be: onSuccess, onSettled, describeResult (describeResult still called but not reported) + const onSuccessIndex = calls.indexOf("onSuccess"); + const onSettledIndex = calls.indexOf("onSettled"); + assertEquals(onSuccessIndex < onSettledIndex, true); +}); + +test("optimistic update with no describeResult: no success message", async () => { + vi.useFakeTimers(); + const { client, successMessages } = createTestMutationClient(); + const calls: string[] = []; + + const mutTest = client.define({ + mutate: async () => { + await delay(100); + return "result"; + }, + describe: () => "test", + describeResult: null, + optimistic: ({ onSuccess }) => { + onSuccess(() => calls.push("success")); + calls.push("optimistic"); + }, + refetchOnSuccess: false, + }); + + mutTest.run(); + await vi.advanceTimersByTimeAsync(150); + + assertEquals(successMessages, [], "No success message when describeResult is null"); + assertEquals(calls.includes("success"), true, "onSuccess callback still called"); +}); diff --git a/test/runWithOptions.test.tsx b/test/runWithOptions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf166a73a71943fdfc379cec047c7c1cf55f2a78 --- /dev/null +++ b/test/runWithOptions.test.tsx @@ -0,0 +1,68 @@ +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; + }, + optimistic: ({ onSuccess }) => { + onSuccess(() => {}); + }, + refetchOnSuccess: false, + describe: "Test the action", + describeResult: "Tested the action", + }); + + 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 = []; + await act(async () => { + vi.advanceTimersByTime(10000); + }); + assertEquals(renders, []); + renders = []; +}); diff --git a/test/setError.test.tsx b/test/setError.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..650263ec1e03f7cc0ded262f57f4f1158bc39ee9 --- /dev/null +++ b/test/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/share.ts b/test/share.ts index bf7d7bb81ec99cec0e6c0c694ccc28d987012707..834a5ccb446365a776609832b34e2ad7404eadce 100644 --- a/test/share.ts +++ b/test/share.ts @@ -1,3 +1,4 @@ +/* v8 ignore start -- @preserve */ import { MutationClient } from "../src/client.ts"; export interface TestMutationClient { diff --git a/test/snapshot.test.tsx b/test/snapshot.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d1d1600f099f78618f334e003d77ec061877233 --- /dev/null +++ b/test/snapshot.test.tsx @@ -0,0 +1,231 @@ +import { useMutate } from "@clo/react-mutation"; +import { assert, 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("snapshot should skip no-op mutation", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + let state = { value: "initial" }; + let failed = false; + const mutUpdate = client.define({ + mutate: async (newValue: string) => { + failed = true; + }, + optimistic: ({ args: [newValue] }) => { + state.value = newValue; + }, + snapshot: () => state.value, + describe: "Update value", + describeResult: "Updated value", + }); + + let renders: Array<{ status: string; isOptimisticData: boolean }> = []; + function TestComponent() { + const { run, status, isOptimisticData } = useMutate(mutUpdate); + renders.push({ status, isOptimisticData }); + return ( + + ); + } + + render(); + assertEquals(renders, [{ status: "idle", isOptimisticData: false }]); + renders = []; + + // Click to run mutation with same value (no-op) + await act(() => user.click(screen.getByTestId("btn"))); + vi.runAllTimers(); + + // Should skip the mutation and return to idle without calling API + assert(!failed); + assertEquals(state.value, "initial"); + assertEquals(renders, []); // nothing changed + assertEquals(successMessages, []); // nothing happened + assertEquals(errorMessages, []); +}); + +test("snapshot should allow mutation when value changes", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + let state = { value: "initial" }; + + const mutUpdate = client.define({ + mutate: async (newValue: string) => { + return (await s.next()).value; + }, + optimistic: ({ args: [newValue] }) => { + state.value = newValue; + }, + snapshot: () => state.value, + describe: "Update value", + describeResult: "Updated value", + }); + + let renders: Array<{ status: string; isOptimisticData: boolean }> = []; + function TestComponent() { + const { run, status, isOptimisticData } = useMutate(mutUpdate); + renders.push({ status, isOptimisticData }); + return ( + + ); + } + + render(); + renders = []; + + // Click to run mutation with different value + await act(() => user.click(screen.getByTestId("btn"))); + assertEquals(state.value, "changed"); // Optimistic applied + assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]); + renders = []; + + // Complete the mutation + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + + assertEquals(renders, [{ status: "success", isOptimisticData: false }]); + assertEquals(successMessages, ["Updated value"]); + assertEquals(errorMessages, []); +}); + +test("debounced snapshot should skip when final state equals initial", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + let state = { value: "initial" }; + + const mutations: string[] = []; + const mutUpdate = client.define({ + mutate: async (newValue: string) => { + mutations.push(newValue); + }, + optimistic: ({ args: [newValue] }) => { + state.value = newValue; + }, + snapshot: () => state.value, + debounceMs: 500, + refetchOnSuccess: false, + describe: "Update value", + describeResult: "Updated value", + }); + + let renders: Array<{ status: string }> = []; + function TestComponent() { + const { run, status } = useMutate(mutUpdate); + renders.push({ status }); + return ( + <> + + + + ); + } + + render(); + assertEquals(renders, [{ status: "idle" }]); + renders = []; + + // First call - change value + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(state.value, "changed"); + assertEquals(renders, []); // Debounced mutations don't show mutating, no update + assertEquals(mutations, []); + renders = []; + + // Second call - revert to initial (no-op overall) + await act(() => user.click(screen.getByTestId("b"))); + assertEquals(state.value, "initial"); // Should be rolled back to initial + assertEquals(renders, []); // Still idle + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + assertEquals(mutations, []); + + await act(() => vi.runAllTimers()); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + assertEquals(mutations, []); +}); + +test("debounced snapshot should mutate when final differs from initial", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + let state = { value: "initial" }; + + const mutUpdate = client.define({ + mutate: async (newValue: string) => { + return (await s.next()).value; + }, + optimistic: ({ args: [newValue] }) => { + state.value = newValue; + }, + snapshot: () => state.value, + debounceMs: 500, + refetchOnSuccess: false, + describe: "Update value", + describeResult: "Updated value", + }); + + let renders: Array<{ status: string; result: string | undefined }> = []; + function TestComponent() { + const { run, status, result } = useMutate(mutUpdate); + renders.push({ status, result }); + return ( + <> + + + + ); + } + + render(); + renders = []; + + // First call + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(state.value, "temp"); + renders = []; + + // Second call - different from initial + await act(() => user.click(screen.getByTestId("b"))); + assertEquals(state.value, "final"); + renders = []; + + // Wait for debounce + await act(async () => { + vi.advanceTimersByTime(500); + }); + assertEquals(renders, [{ status: "mutating", result: undefined }]); + renders = []; + + // Complete mutation + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(renders, [{ status: "success", result: "ok" }]); + assertEquals(successMessages, []); +});