diff --git a/jsr.json b/jsr.json index db194e6070cf6f6c3c6a1c06ae6b816171eb3014..85a7159f32565ad14a17132bd0e62a719b72f355 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/package-lock.json b/package-lock.json index 225395651ff0703ebc8f7621397a4e072ec20a39..c52f6544b3c5e5689dcb053dc59f4d358df6c5bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@clo/react-mutation", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clo/react-mutation", - "version": "0.0.0", + "version": "0.1.0", "license": "ISC", "dependencies": { + "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", "@std/assert": "npm:@jsr/std__assert@^1.0.17" }, "devDependencies": { @@ -315,6 +316,12 @@ "node": ">=6.9.0" } }, + "node_modules/@clo/lib": { + "name": "@jsr/clo__lib", + "version": "3.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz", + "integrity": "sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", diff --git a/package.json b/package.json index 1b953608c49f3fb704d7da477075e8291a710fd0..f0bd2a337048fa543ee3fbf0bc169016c763729c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "check": "tsc --noEmit" }, "dependencies": { + "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", "@std/assert": "npm:@jsr/std__assert@^1.0.17" }, "devDependencies": { diff --git a/src/batch.ts b/src/batch.ts index 50c47a8cb6f2ff4892eef134ab193ca87161676b..1e1e6540bff9a89dd1e21a903a13c5a05b12c2d8 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -1,6 +1,7 @@ import type { MutationClient, MutationClientFromConfig } from "./client.ts"; import type { MutationClientConfig } from "./client.ts"; import type { Mutation, MutationEvent } from "./types.ts"; +import { message as errMessage } from "@clo/lib/error.ts"; export interface BatchMutationOptions< Args extends unknown[], @@ -124,6 +125,7 @@ export class BatchMutation< #options: BatchMutationOptions; #client: MutationClientFromConfig; #channels: Map> = new Map(); + client: MutationClientFromConfig; constructor( client: MutationClient, @@ -131,6 +133,7 @@ export class BatchMutation< ) { this.#options = options; this.#client = client; + this.client = client; } key(args: Args): string { @@ -212,7 +215,10 @@ export class BatchMutation< return describe; } - describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { + // Not available for batched mutations - success reporting happens during commit + describeResult: undefined = undefined; + + #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { const { describeResult } = this.#options; if (describeResult === null || describeResult === undefined) return undefined; return typeof describeResult === "function" @@ -229,7 +235,8 @@ export class BatchMutation< /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ run(...args: Args): void { this.#runAndReturn(args, true).catch((error) => { - this.#client.reportError(error); + const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + this.#client.reportError(message, error); }); } @@ -400,7 +407,7 @@ export class BatchMutation< // Report success globally if any of the pending items requested it const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); if (shouldReportSuccess) { - const message = this.describeResult(firstArgs, initial, current, result); + const message = this.#describeResult(firstArgs, initial, current, result); if (message && this.#client.reportSuccess) { this.#client.reportSuccess(message); } @@ -420,7 +427,8 @@ export class BatchMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - this.#client.reportError(result.reason); + const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); } }); }).finally(() => { @@ -458,7 +466,8 @@ export class BatchMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - this.#client.reportError(result.reason); + const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); } }); }).finally(() => { diff --git a/src/client.ts b/src/client.ts index 079c68afc182472494331f5910c2b2a5020e1b11..edda210b0d76bb76be7a0daf48e76fafdc7fbedf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,7 +21,7 @@ export interface MutationClientOptions< getOptimisticHelpers: ( events: OptimisticEvents, ) => OptimisticHelpers; - reportError: (error: unknown) => void; + reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; /** * Compare two values for deep equality. Used by BatchMutation to determine @@ -42,7 +42,7 @@ export class MutationClient< > { context: Context; getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; - reportError: (error: unknown) => void; + reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; deepEquals: (a: unknown, b: unknown) => boolean; diff --git a/src/queued.ts b/src/queued.ts index 5a60eb64314be27f95f55734602e46a51b2e42ee..992c3046121cd25533b87238041948f16526cf20 100644 --- a/src/queued.ts +++ b/src/queued.ts @@ -1,6 +1,7 @@ import type { MutationClient, MutationClientFromConfig } from "./client.ts"; import type { MutationClientConfig } from "./client.ts"; import type { Mutation, MutationEvent } from "./types.ts"; +import { message as errMessage } from "@clo/lib/error.ts"; /** * Argument to `defineMutation`. @@ -96,6 +97,7 @@ export class QueuedMutation< #options: MutationOptions; #client: MutationClientFromConfig; #queues: Map> = new Map(); + client: MutationClientFromConfig; constructor( client: MutationClient, @@ -103,6 +105,7 @@ export class QueuedMutation< ) { this.#options = options; this.#client = client; + this.client = client; } key(args: Args) { @@ -180,7 +183,8 @@ export class QueuedMutation< this.#client.reportSuccess(message); } }).catch((error) => { - this.#client.reportError(error); + const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + this.#client.reportError(message, error); }); } @@ -287,7 +291,8 @@ export class QueuedMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - this.#client.reportError(result.reason); + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); } }); }).finally(() => { @@ -328,7 +333,8 @@ export class QueuedMutation< // Report any errors from refetch or callbacks results.forEach((result) => { if (result.status === "rejected") { - this.#client.reportError(result.reason); + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); } }); }).finally(() => { diff --git a/src/react.tsx b/src/react.tsx index d8379fb345ffa69336d67dfe0128f8afcb07b478..278ad496f818ef6efe35dca6b6bd262153191c1e 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -7,6 +7,7 @@ import { useEffect, useState, } from "react"; +import { message as errMessage } from "@clo/lib/error.ts"; import type { Mutation } from "./types.ts"; /** @@ -46,6 +47,7 @@ export interface UseMutateSuccess { status: "success"; result: Result; error: undefined; + errorMessage: undefined; /** `true` when a `mutate` function is currently running. */ isMutating: false; /** `true` when a loading indicator should be shown. */ @@ -61,6 +63,8 @@ export interface UseMutateError { status: "error"; result: undefined; error: unknown; + /** User-friendly in this format: `Failed to {action}: {details}` */ + errorMessage: string; /** `true` when a `mutate` function is currently running. */ isMutating: false; /** `true` when a loading indicator should be shown. */ @@ -76,6 +80,7 @@ export interface UseMutateIdle { status: "idle" | "mutating"; result: undefined; error: undefined; + errorMessage: undefined; /** `true` when a `mutate` function is currently running. */ isMutating: boolean; /** `true` when a loading indicator should be shown. */ @@ -91,12 +96,13 @@ export interface UseMutateIdle { type AnyMutationState = & Omit< UseMutateIdle, - "status" | "result" | "error" | "isSuccess" | "isError" + "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" > & { status: "idle" | "mutating" | "error" | "success"; result: undefined | Result; error: undefined | unknown; + errorMessage: undefined | string; isSuccess: boolean; isError: boolean; }; @@ -106,6 +112,7 @@ function initialState() { status: "idle", result: undefined, error: undefined, + errorMessage: undefined, isMutating: false, isPending: false, isSuccess: false, @@ -119,6 +126,7 @@ class Observer { mutation: Mutation | null = null; unsubscribe: (() => void) | null = null; currentKey: string | null = null; + currentArgs: Args | null = null; constructor(setRerender: (fn: number) => void) { this.setRerender = setRerender; @@ -147,10 +155,20 @@ class Observer { this.state = initialState(); } + computeErrorMessage(error: unknown): string | undefined { + if (!error) return undefined; + const mutation = this.mutation; + if (!mutation || !this.currentArgs) return errMessage(error); + return `Failed to ${mutation.describe(...this.currentArgs)}: ${ + errMessage(error) + }`; + } + binding: UseMutateResult = ((self: this) => ({ run(...args: Args) { const mutation = self.mutation; if (!mutation) return; + self.currentArgs = args; const key = mutation.key(args); if (key !== self.currentKey) { self.currentKey = key; @@ -178,6 +196,7 @@ class Observer { ? "mutating" : "idle", error: error ?? undefined, + errorMessage: self.computeErrorMessage(error ?? undefined), result: result ?? undefined, isMutating: status === "mutating", isPending: status === "mutating" || status === "refetching", @@ -189,19 +208,28 @@ class Observer { }, ); } - // use global error/success handling if this usage of the hook doesn't check for + // Use global error/success handling if this usage of the hook doesn't check for // errors or success. This makes it act pretty awesome in terms of defaults. - // You don't have to worry about the errors/successes, they'll surface exactly once. - if ( - self.watched.has("isError") || self.watched.has("error") || - self.watched.has("isSuccess") || self.watched.has("result") - ) { - mutation.runAndReturn(...args).catch(() => { - // caught in event listener + // You don't have to worry about result UI, they'll surface exactly once. + const watchesError = self.watched.has("isError") || + self.watched.has("error") || self.watched.has("errorMessage"); + const watchesSuccess = self.watched.has("isSuccess") || + self.watched.has("result"); + mutation.runAndReturn(...args) + .then((result) => { + if (!watchesSuccess && mutation.describeResult) { + const message = mutation.describeResult(args, result); + if (message && mutation.client.reportSuccess) { + mutation.client.reportSuccess(message); + } + } + }) + .catch((err) => { + if (!watchesError) { + const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; + mutation.client.reportError(message, err); + } }); - } else { - mutation.run(...args); - } }, clear() { self.setState({ @@ -211,6 +239,7 @@ class Observer { isError: false, isSuccess: false, error: undefined, + errorMessage: undefined, result: undefined, }); }, @@ -226,6 +255,10 @@ class Observer { self.watched.add("error"); return self.state.error; }, + get errorMessage() { + self.watched.add("errorMessage"); + return self.state.errorMessage; + }, get isMutating() { self.watched.add("isMutating"); return self.state.isMutating; diff --git a/src/tanstack-query.ts b/src/tanstack-query.ts index d4c3775295687864978a825a017b7ba2d1c6fa20..1a8be40fedff811f56cdb350e4afb43ff3669fe4 100644 --- a/src/tanstack-query.ts +++ b/src/tanstack-query.ts @@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers { } /** - * Remove items from an array that match a predicate. + * Remove items from an array that match `filter`. * If the query or path doesn't exist, the updater is skipped. */ objArrayRemove< @@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers { >( queryKey: QueryKeyAndFn, path: Path, - predicate: ( + filter: ( item: GetObjectPath extends Array ? T : never, index: number, ) => boolean, @@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers { const { value: original, exists } = getPath(prev, path); if (!exists || !Array.isArray(original)) return; - const newArray = original.filter((item, index) => !predicate(item, index)); + const newArray = original.filter((item, index) => !filter(item, index)); this.#set( queryKey, (obj) => obj ? setPath(obj, path, newArray as any) : obj, @@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers { >( queryKey: QueryKeyAndFn, path: Path, - predicate: ( - item: GetObjectPath extends Array ? T : never, - index: number, - ) => boolean, - updater: ( - item: GetObjectPath extends Array ? T : never, - ) => GetObjectPath extends Array ? T : never, + { filter, update }: { + filter: ( + item: GetObjectPath extends Array ? T : never, + index: number, + ) => boolean; + update: ( + item: GetObjectPath extends Array ? T : never, + ) => GetObjectPath extends Array ? T : never; + }, ) { const prev = this.#get(queryKey); if (!prev) return; @@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers { if (!exists || !Array.isArray(original)) return; const newArray = original.map((item, index) => - predicate(item, index) ? updater(item) : item + filter(item, index) ? update(item) : item ); this.#set( queryKey, @@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers { } /** - * Remove items from an array that match a predicate. + * Remove items from an array that match a `filter`. * If the query or path doesn't exist, the updater is skipped. */ arrayRemove( queryKey: QueryKeyAndFn, - predicate: ( + filter: ( item: Data, index: number, ) => boolean, @@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers { const prev = this.#get(queryKey); if (!prev || !Array.isArray(prev)) return; - const newArray = prev.filter((item, index) => !predicate(item, index)); + const newArray = prev.filter((item, index) => !filter(item, index)); this.#set(queryKey, newArray); this.#onRestore(() => { // TODO: splice items back in case original changed @@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers { } /** - * Update items in an array that match a predicate. + * Update items in an array that match a `filter`. * If the query or path doesn't exist, the updater is skipped. */ arrayUpdate( queryKey: QueryKeyAndFn, - predicate: ( - item: Data, - index: number, - ) => boolean, - updater: (item: Data) => Data, + { + filter, + update, + }: { + filter?: (item: Data, index: number) => boolean; + update: (item: Data) => Data; + }, ) { const prev = this.#get(queryKey); if (!prev || !Array.isArray(prev)) return; const newArray = prev.map((item, index) => - predicate(item, index) ? updater(item) : item + (filter ? filter(item, index) : true) ? update(item) : item ); this.#set(queryKey, newArray); this.#onRestore(() => { diff --git a/src/types.ts b/src/types.ts index 0be93c047e6841786a8a3d2f57a9fb20d5e37999..0013dbba186c39bdfa2aa8ffe8dd6f5e8dc3782a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { MutationClient } from "./client.ts"; + export interface Mutation { /** Calling the mutation. Errors are turned into UI toasts. */ run(...args: Args): void; @@ -12,6 +14,8 @@ export interface Mutation { cb: (update: MutationEvent) => void, ): () => void; describe(...args: Args): string; + describeResult?: (args: Args, result: Result) => string | undefined; + client: MutationClient; } export interface MutationEvent { diff --git a/test/batch.test.ts b/test/batch.test.ts index 074b6160166044210803cfbbbde1ea1d21dc761b..50a6ca26a285d7d4fb1503b12531665295478fe5 100644 --- a/test/batch.test.ts +++ b/test/batch.test.ts @@ -31,7 +31,7 @@ function createTestClient() { }, }; }, - reportError(error) { + reportError(message, error) { errors.push(error); }, }); @@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { }, }; }, - reportError(error) { + reportError(message, error) { errors.push(error); }, }); @@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => { }, }; }, - reportError(error) { + reportError(message, error) { errors.push(error); }, deepEquals(a, b) { diff --git a/test/object-path-types.test.ts b/test/object-path-types.test.ts index 02ec38aa27a41dc2032d97bab2d6e55ffe3cc294..e6fa6989ee5a8615949999f224bf996ded32061e 100644 --- a/test/object-path-types.test.ts +++ b/test/object-path-types.test.ts @@ -3,16 +3,13 @@ * These tests verify that TypeScript types work correctly at compile time */ -import type { - AllObjectPaths, - GetObjectPath, -} from "../src/object-path.ts"; +import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; // Type testing utilities type Expect = T; -type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y - ? 1 - : 2 ? true +type Equal = (() => T extends X ? 1 : 2) extends + () => T extends Y ? 1 + : 2 ? true : false; type NotEqual = Equal extends true ? false : true; type IsAny = 0 extends 1 & T ? true : false; @@ -348,7 +345,7 @@ type ArrayRemoveSignature< Data extends object, Path extends AllObjectPaths, > = GetObjectPath extends readonly (infer T)[] - ? (path: Path, predicate: (item: T, index: number) => boolean) => void + ? (path: Path, filter: (item: T, index: number) => boolean) => void : never; declare const arrayRemoveItems: ArrayRemoveSignature; @@ -379,8 +376,7 @@ type IncrementNameTest = Expect< type ToggleSignature< Data extends object, Path extends AllObjectPaths, -> = GetObjectPath extends boolean - ? (path: Path) => void +> = GetObjectPath extends boolean ? (path: Path) => void : never; declare const toggle: ToggleSignature; diff --git a/test/queued.test.ts b/test/queued.test.ts index 85f287f1bc6da3ab9415fbae3701b10946dd808d..cc0f9dfee237302e0a90d14d67a00e72b2ee2e2e 100644 --- a/test/queued.test.ts +++ b/test/queued.test.ts @@ -16,7 +16,7 @@ function createTestClient() { }, }; }, - reportError(error) { + reportError(message, error) { errors.push(error); }, }); diff --git a/test/tanstack-query-helpers.test.ts b/test/tanstack-query-helpers.test.ts index a62cb28b301bbc8b9a1a6f1c25d72579975602e3..6096e1a087dec62c28dd1eb8c0ffd2f007cccfc9 100644 --- a/test/tanstack-query-helpers.test.ts +++ b/test/tanstack-query-helpers.test.ts @@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => { helpers.objArrayUpdate( queryTest, ["items"], - (item) => item.id === 2, - (item) => ({ ...item, label: "UPDATED" }), + { + filter: (item) => item.id === 2, + update: (item) => ({ ...item, label: "UPDATED" }), + }, ); const result = client.getQueryData(queryTest.queryKey); @@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => { helpers.objArrayUpdate( queryTest, ["items"], - (item) => item.id > 1, - (item) => ({ ...item, label: item.label.toUpperCase() }), + { + filter: (item) => item.id > 1, + update: (item) => ({ ...item, label: item.label.toUpperCase() }), + }, ); const result = client.getQueryData(queryTest.queryKey); @@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => { helpers.objArrayUpdate( queryTest, ["items"], - (_item, index) => index === 0, - (item) => ({ ...item, label: "FIRST" }), + { + filter: (_item, index) => index === 0, + update: (item) => ({ ...item, label: "FIRST" }), + }, ); const result = client.getQueryData(queryTest.queryKey);