From ea5d25fad4f41fa5631a62cb4de13ff35d6d5277 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 30 Jan 2026 13:49:44 -0800 Subject: [PATCH] fix: a ton of bugs because i didn't write tests --- jsr.json | 2 +- package-lock.json | 29 ++ package.json | 1 + src/mutation.ts | 110 ++++-- src/react.ts | 18 +- src/types.ts | 3 + test/debounce.test.tsx | 274 +++++++++++++++ test/mutations.test.ts | 48 --- test/optimistic.test.tsx | 117 +++++++ test/ordering.test.ts | 426 +++++++++++++++++++++++ test/{cases => }/runWithOptions.test.tsx | 2 +- test/{cases => }/setError.test.tsx | 2 +- test/share.ts | 1 + test/{cases => }/snapshot.test.tsx | 2 +- 14 files changed, 948 insertions(+), 87 deletions(-) create mode 100644 test/debounce.test.tsx delete mode 100644 test/mutations.test.ts create mode 100644 test/optimistic.test.tsx create mode 100644 test/ordering.test.ts rename test/{cases => }/runWithOptions.test.tsx (96%) rename test/{cases => }/setError.test.tsx (98%) rename test/{cases => }/snapshot.test.tsx (98%) 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/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/cases/runWithOptions.test.tsx b/test/runWithOptions.test.tsx similarity index 96% rename from test/cases/runWithOptions.test.tsx rename to test/runWithOptions.test.tsx index 74c8de639e2a683149be993394834d64b51fdbc9..bf166a73a71943fdfc379cec047c7c1cf55f2a78 100644 --- a/test/cases/runWithOptions.test.tsx +++ b/test/runWithOptions.test.tsx @@ -3,7 +3,7 @@ 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"; +import { createTestMutationClient, IterableStream } from "./share.ts"; test("runWithOptions should allow react hook to do local handling", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); diff --git a/test/cases/setError.test.tsx b/test/setError.test.tsx similarity index 98% rename from test/cases/setError.test.tsx rename to test/setError.test.tsx index 213586b1a4f587d6f0cbb7a968520ad6f3aa4a17..650263ec1e03f7cc0ded262f57f4f1158bc39ee9 100644 --- a/test/cases/setError.test.tsx +++ b/test/setError.test.tsx @@ -3,7 +3,7 @@ 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"; +import { createTestMutationClient, IterableStream } from "./share.ts"; test("setError should manually set error state on the hook", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); 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/cases/snapshot.test.tsx b/test/snapshot.test.tsx similarity index 98% rename from test/cases/snapshot.test.tsx rename to test/snapshot.test.tsx index a71627aabc99e2020f33e0b0f1f81d94101a9443..9d1d1600f099f78618f334e003d77ec061877233 100644 --- a/test/cases/snapshot.test.tsx +++ b/test/snapshot.test.tsx @@ -3,7 +3,7 @@ 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"; +import { createTestMutationClient, IterableStream } from "./share.ts"; test("snapshot should skip no-op mutation", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); -- 2.54.0