diff --git a/jsr.json b/jsr.json index 1b9ddcaf6e684aaa2f420e292d895f2c049ffb62..7e15fdff1fec14af30899e479e0a1fc49589bb87 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.11", + "version": "1.0.0-beta.12", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/src/client.ts b/src/client.ts index 5f4fa44b1fe220a857fd35d628323fde7212ec7c..4e355003744687b164ff770be50f5b5904e3c7d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,3 @@ -import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; import { BlockingMutation, type MutationOptions } from "./mutation.ts"; import type { Mutation } from "./types.ts"; diff --git a/src/mutation.ts b/src/mutation.ts index 80d3c62dbfc5e977b29a198135178601e8ef289e..614ea3dce73e3762ecbd2f418a1ba4088745aec7 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -27,7 +27,7 @@ export interface MutationOptions< mutate: (this: Config["context"], ...args: Args) => Promise; /** * Used in error messages and debug tools. - * Phrase it considering the template `Failed to ${describe(...)}` + * Phrase it considering the template `Could not ${describe(...)}` */ describe: string | ((context: Config["context"] & { args: Args }) => string); /** @@ -263,8 +263,7 @@ export class BlockingMutation< // Call global handler unless suppressed if (!suppressGlobalError) { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); + this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); } }); @@ -405,7 +404,7 @@ export class BlockingMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + const message = `Failed to refetch data: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); @@ -442,7 +441,7 @@ export class BlockingMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + const message = `Failed to refetch data: ${errMessage(result.reason)}`; this.#client.reportError(message, result.reason); } }); @@ -638,8 +637,13 @@ export class BlockingMutation< // Check if there are any listeners at execution time const hasListeners = channel.listeners.size > 0; if (!hasListeners) { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); + this.#client.reportError( + formatFriendlyError( + this.describe(...args), + error, + ), + error, + ); } }, ); @@ -660,3 +664,12 @@ export class BlockingMutation< // Otherwise, it will execute when the current item finishes } } + +export function formatFriendlyError( + description: string | null, + error: unknown, +) { + if (!description || !description[0]) return `Internal Error: ${errMessage(error)}`; + description = description[0].toLowerCase() + description.slice(1); + return `Could not ${description}: ${errMessage(error)}`; +} diff --git a/src/react.ts b/src/react.ts index fa7e10bf3f78745978d687e9008f4162d118a9fb..e4ed2e5b10ae97a2afdfe7dcf422fb8f8947f48b 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,4 +1,5 @@ import { message as errMessage } from "@clo/lib/error.ts"; +import type { Timer } from "@clo/lib/ts.ts"; import { type FC, type MouseEvent, @@ -9,6 +10,7 @@ import { useState, } from "react"; import { jsx } from "react/jsx-runtime"; +import { formatFriendlyError } from "./mutation.ts"; import type { Mutation, RunOptions } from "./types.ts"; /** @@ -69,7 +71,7 @@ export interface UseMutateError { status: "error"; result: undefined; error: unknown; - /** User-friendly in this format: `Failed to {action}: {details}` */ + /** User-friendly in this format: `Could not {action}: {details}` */ errorMessage: string; /** `true` when a `mutate` function is currently running. */ isMutating: false; @@ -138,6 +140,7 @@ class Observer { mutation: Mutation | null = null; unsubscribe: (() => void) | null = null; currentKey: string | null = null; + pendingTimer: Timer | null = null; constructor(setRerender: (fn: number) => void) { this.setRerender = setRerender; @@ -166,11 +169,19 @@ class Observer { this.state = initialState(); } + resetPending() { + this.setState({ isPending: false }); + if (this.pendingTimer) clearTimeout(this.pendingTimer); + this.pendingTimer = null; + } + computeErrorMessage(error: unknown): string | undefined { if (!error) return undefined; const mutation = this.mutation; - if (!mutation || !this.state.args) return errMessage(error); - return `Failed to ${mutation.describe(...this.state.args)}: ${errMessage(error)}`; + return formatFriendlyError( + this.state.args ? mutation?.describe(...this.state.args) ?? null : null, + error, + ); } run(...args: Args) { @@ -187,9 +198,9 @@ class Observer { if (status === "idle") { this.setState({ isMutating: false, - isPending: false, isOptimisticData: false, }); + this.resetPending(); return; } const hasError = error != null; @@ -204,16 +215,25 @@ class Observer { ? "mutating" : "idle", error: error ?? undefined, - errorMessage: this.computeErrorMessage(error ?? undefined), + errorMessage: this.state.error === error && this.state.errorMessage + ? this.state.errorMessage + : this.computeErrorMessage(error ?? undefined), result: result ?? undefined, isMutating: status === "mutating", - isPending: status === "mutating" || status === "refetching", isSuccess: hasResult && !hasError, isError: hasError, isOptimisticData: status === "waiting" || status === "mutating" || status === "refetching", args: hasError || hasResult ? undefined : this.state.args, }); + if (!this.state.isPending && this.state.isMutating) { + this.pendingTimer = setTimeout(() => { + this.pendingTimer = null; + this.setState({ isPending: true }); + }, 200); + } else { + this.resetPending(); + } }, ); } @@ -235,8 +255,7 @@ class Observer { }); promise.catch((err) => { if (!watchesError) { - const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; - mutation.client.reportError(message, err); + mutation.client.reportError(formatFriendlyError(mutation.describe(...args), errMessage(err)), err); } }); return promise; @@ -279,7 +298,9 @@ class Observer { ? "mutating" : "idle", error: error ?? undefined, - errorMessage: this.computeErrorMessage(error ?? undefined), + errorMessage: this.state.error === error && this.state.errorMessage + ? this.state.errorMessage + : this.computeErrorMessage(error ?? undefined), result: result ?? undefined, isMutating: status === "mutating", isPending: status === "mutating" || status === "refetching", diff --git a/test/mutations.test.ts b/test/mutations.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8843fbff0260fa79553a070041b06d1763f44ab --- /dev/null +++ b/test/mutations.test.ts @@ -0,0 +1,6 @@ +import { test } from "vitest"; +import { createTestMutationClient } from "./share.ts"; + +test("apply optimistic update, refetch when done", () => { + const { client } = createTestMutationClient(); +}); diff --git a/test/share.ts b/test/share.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf7d7bb81ec99cec0e6c0c694ccc28d987012707 --- /dev/null +++ b/test/share.ts @@ -0,0 +1,79 @@ +import { MutationClient } from "../src/client.ts"; + +export interface TestMutationClient { + client: MutationClient<{}, {}>; + errorMessages: Array<{ message: string; error: unknown }>; + successMessages: string[]; +} + +export function createTestMutationClient(): TestMutationClient { + const errorMessages: Array<{ message: string; error: unknown }> = []; + const successMessages: string[] = []; + + const client = new MutationClient({ + context: {}, + getOptimisticHelpers: () => ({}), + reportError: (message: string, error: unknown) => { + errorMessages.push({ message, error }); + }, + reportSuccess: (message: string) => { + successMessages.push(message); + }, + }); + + return { client, errorMessages, successMessages }; +} + +export class IterableStream implements AsyncIterableIterator { + #buffer: T[] = []; + #listeners = new Set<{ + resolve(value: IteratorResult): void; + reject(error: unknown): void; + }>(); + #ended = false; + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } + + async next(): Promise> { + if (this.#buffer.length > 0) { + return { done: false, value: this.#buffer.shift()! }; + } + if (this.#ended) { + return { done: true, value: undefined }; + } + return new Promise((resolve, reject) => this.#listeners.add({ resolve, reject })); + } + + push(value: T) { + if (this.#listeners.size > 0) { + const listeners = [...this.#listeners]; + this.#listeners.clear(); + for (const listener of listeners) { + listener.resolve({ done: false, value }); + } + } else { + this.#buffer.push(value); + } + } + + throw(error: unknown) { + this.#buffer = []; + for (const listener of this.#listeners) { + listener.reject(error); + } + this.#listeners.clear(); + return Promise.resolve({ done: true, value: undefined } as IteratorResult); + } + + end() { + this.#ended = true; + if (this.#listeners.size > 0) { + for (const listener of this.#listeners) { + listener.resolve({ done: true, value: undefined }); + } + this.#listeners.clear(); + } + } +} diff --git a/test/useMutate.test.tsx b/test/useMutate.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e4a04c66385b3a6536043a8deca7d0f6b47cb392 --- /dev/null +++ b/test/useMutate.test.tsx @@ -0,0 +1,343 @@ +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 { useMutate } from "../src/react.ts"; +import { createTestMutationClient, IterableStream } from "./share.ts"; + +test("useMutate - global error and success handling", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + let renders: Array<{ isMutating: boolean; isPending: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending } = useMutate(mutTest); + renders.push({ isMutating, isPending }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true }]); + renders = []; + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ isMutating: false, isPending: false }]); + renders = []; + + // mutation 2 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true }]); + renders = []; + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + const error1 = new Error("damn!"); + await act(async () => { + s.throw(error1); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, [ + { error: error1, message: "Could not test the action: damn!" }, + ]); +}); + +test("useMutate - local error and success handling", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + let renders: Array< + { + isMutating: boolean; + isPending: boolean; + result: string | undefined; + error: unknown; + errorMessage: string | undefined; + isSuccess: boolean; + isError: boolean; + isOptimisticData: boolean; + } + > = []; + function TestComponent() { + const { run, clear, isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData } = + useMutate( + mutTest, + ); + renders.push({ isMutating, isPending, result, error, errorMessage, isSuccess, isError, isOptimisticData }); + + return ( + <> + + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: false, + isOptimisticData: false, + isPending: false, + isSuccess: false, + result: undefined, + }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 - success + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: true, + isOptimisticData: true, + isPending: false, + isSuccess: false, + result: undefined, + }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: true, + isOptimisticData: true, + isPending: true, + isSuccess: false, + result: undefined, + }]); + renders = []; + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: false, + isOptimisticData: false, + isPending: false, + isSuccess: true, + result: "ok", + }]); + renders = []; + + // mutation 2 - failure + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: true, + isOptimisticData: true, + isPending: false, + isSuccess: false, + result: undefined, + }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ + error: undefined, + errorMessage: undefined, + isError: false, + isMutating: true, + isOptimisticData: true, + isPending: true, + isSuccess: false, + result: undefined, + }]); + renders = []; + const error1 = new Error("damn!"); + await act(async () => { + s.throw(error1); + vi.advanceTimersByTime(100); + }); + assertEquals(renders, [{ + error: error1, + errorMessage: "Could not test the action: damn!", + isError: true, + isMutating: false, + isOptimisticData: false, + isPending: false, + isSuccess: false, + result: undefined, + }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); +}); + +test("useMutate - global error and success handling", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ delay: null }); + + const { client, successMessages, errorMessages } = createTestMutationClient(); + const s = new IterableStream(); + + const mutTest = client.define({ + mutate: async () => { + return (await s.next()).value; + }, + describe: "Test the action", + describeResult: "Tested the action", + optimistic: () => {}, + }); + + let renders: Array<{ isMutating: boolean; isPending: boolean }> = []; + function TestComponent() { + const { run, isMutating, isPending } = useMutate(mutTest); + renders.push({ isMutating, isPending }); + + return ( + <> + + + ); + } + + render(); + // initial state + assertEquals(renders, [{ isMutating: false, isPending: false }]); + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + renders = []; + vi.runAllTimers(); + + // mutation 1 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true }]); + renders = []; + assertEquals(successMessages, []); + assertEquals(errorMessages, []); + await act(async () => { + s.push("ok"); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + assertEquals(renders, [{ isMutating: false, isPending: false }]); + renders = []; + + // mutation 2 + await act(() => user.click(screen.getByTestId("a"))); + assertEquals(renders, [{ isMutating: true, isPending: false }]); + renders = []; + await act(() => vi.advanceTimersByTime(150)); + assertEquals(renders, []); + await act(() => vi.advanceTimersByTime(50)); + assertEquals(renders, [{ isMutating: true, isPending: true }]); + renders = []; + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, []); + const error1 = new Error("damn!"); + await act(async () => { + s.throw(error1); + vi.advanceTimersByTime(100); + }); + assertEquals(successMessages, ["Tested the action"]); + assertEquals(errorMessages, [ + { error: error1, message: "Could not test the action: damn!" }, + ]); +});