| author | |
| committer | |
| log | 331a3e7e7454101d1933ba3d28996ad554a0bf6a |
| tree | 8160eeedbf1b0891674630fb694e639fed662619 |
| parent | de7e1ab0f88b02444249d69a59c5fed7c981b6c9 |
| signature |
13 files changed, 132 insertions(+), 66 deletions(-)
jsr.json+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/react-mutation", | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "1.0.0-beta.1", | 3 | "version": "1.0.0-beta.2", |
| 4 | "exports": { | 4 | "exports": { |
| 5 | ".": "./src/mod.ts", | 5 | ".": "./src/mod.ts", |
| 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", | 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", |
package-lock.json+9-2| ... | @@ -1,14 +1,15 @@ | ... | @@ -1,14 +1,15 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/react-mutation", | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "0.0.0", | 3 | "version": "0.1.0", |
| 4 | "lockfileVersion": 3, | 4 | "lockfileVersion": 3, |
| 5 | "requires": true, | 5 | "requires": true, |
| 6 | "packages": { | 6 | "packages": { |
| 7 | "": { | 7 | "": { |
| 8 | "name": "@clo/react-mutation", | 8 | "name": "@clo/react-mutation", |
| 9 | "version": "0.0.0", | 9 | "version": "0.1.0", |
| 10 | "license": "ISC", | 10 | "license": "ISC", |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", | ||
| 12 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" | 13 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" |
| 13 | }, | 14 | }, |
| 14 | "devDependencies": { | 15 | "devDependencies": { |
| ... | @@ -315,6 +316,12 @@ | ... | @@ -315,6 +316,12 @@ |
| 315 | "node": ">=6.9.0" | 316 | "node": ">=6.9.0" |
| 316 | } | 317 | } |
| 317 | }, | 318 | }, |
| 319 | "node_modules/@clo/lib": { | ||
| 320 | "name": "@jsr/clo__lib", | ||
| 321 | "version": "3.0.0", | ||
| 322 | "resolved": "https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz", | ||
| 323 | "integrity": "sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==" | ||
| 324 | }, | ||
| 318 | "node_modules/@esbuild/aix-ppc64": { | 325 | "node_modules/@esbuild/aix-ppc64": { |
| 319 | "version": "0.27.2", | 326 | "version": "0.27.2", |
| 320 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", | 327 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", |
package.json+1| ... | @@ -13,6 +13,7 @@ | ... | @@ -13,6 +13,7 @@ |
| 13 | "check": "tsc --noEmit" | 13 | "check": "tsc --noEmit" |
| 14 | }, | 14 | }, |
| 15 | "dependencies": { | 15 | "dependencies": { |
| 16 | "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", | ||
| 16 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" | 17 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" |
| 17 | }, | 18 | }, |
| 18 | "devDependencies": { | 19 | "devDependencies": { |
src/batch.ts+14-5| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | import type { MutationClientConfig } from "./client.ts"; | 2 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | 3 | import type { Mutation, MutationEvent } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 4 | 5 | ||
| 5 | export interface BatchMutationOptions< | 6 | export interface BatchMutationOptions< |
| 6 | Args extends unknown[], | 7 | Args extends unknown[], |
| ... | @@ -124,6 +125,7 @@ export class BatchMutation< | ... | @@ -124,6 +125,7 @@ export class BatchMutation< |
| 124 | #options: BatchMutationOptions<Args, Result, Optimistic, Config>; | 125 | #options: BatchMutationOptions<Args, Result, Optimistic, Config>; |
| 125 | #client: MutationClientFromConfig<Config>; | 126 | #client: MutationClientFromConfig<Config>; |
| 126 | #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map(); | 127 | #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map(); |
| 128 | client: MutationClientFromConfig<Config>; | ||
| 127 | 129 | ||
| 128 | constructor( | 130 | constructor( |
| 129 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | 131 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, |
| ... | @@ -131,6 +133,7 @@ export class BatchMutation< | ... | @@ -131,6 +133,7 @@ export class BatchMutation< |
| 131 | ) { | 133 | ) { |
| 132 | this.#options = options; | 134 | this.#options = options; |
| 133 | this.#client = client; | 135 | this.#client = client; |
| 136 | this.client = client; | ||
| 134 | } | 137 | } |
| 135 | 138 | ||
| 136 | key(args: Args): string { | 139 | key(args: Args): string { |
| ... | @@ -212,7 +215,10 @@ export class BatchMutation< | ... | @@ -212,7 +215,10 @@ export class BatchMutation< |
| 212 | return describe; | 215 | return describe; |
| 213 | } | 216 | } |
| 214 | 217 | ||
| 215 | describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { | 218 | // Not available for batched mutations - success reporting happens during commit |
| 219 | describeResult: undefined = undefined; | ||
| 220 | |||
| 221 | #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { | ||
| 216 | const { describeResult } = this.#options; | 222 | const { describeResult } = this.#options; |
| 217 | if (describeResult === null || describeResult === undefined) return undefined; | 223 | if (describeResult === null || describeResult === undefined) return undefined; |
| 218 | return typeof describeResult === "function" | 224 | return typeof describeResult === "function" |
| ... | @@ -229,7 +235,8 @@ export class BatchMutation< | ... | @@ -229,7 +235,8 @@ export class BatchMutation< |
| 229 | /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ | 235 | /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ |
| 230 | run(...args: Args): void { | 236 | run(...args: Args): void { |
| 231 | this.#runAndReturn(args, true).catch((error) => { | 237 | this.#runAndReturn(args, true).catch((error) => { |
| 232 | this.#client.reportError(error); | 238 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; |
| 239 | this.#client.reportError(message, error); | ||
| 233 | }); | 240 | }); |
| 234 | } | 241 | } |
| 235 | 242 | ||
| ... | @@ -400,7 +407,7 @@ export class BatchMutation< | ... | @@ -400,7 +407,7 @@ export class BatchMutation< |
| 400 | // Report success globally if any of the pending items requested it | 407 | // Report success globally if any of the pending items requested it |
| 401 | const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); | 408 | const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); |
| 402 | if (shouldReportSuccess) { | 409 | if (shouldReportSuccess) { |
| 403 | const message = this.describeResult(firstArgs, initial, current, result); | 410 | const message = this.#describeResult(firstArgs, initial, current, result); |
| 404 | if (message && this.#client.reportSuccess) { | 411 | if (message && this.#client.reportSuccess) { |
| 405 | this.#client.reportSuccess(message); | 412 | this.#client.reportSuccess(message); |
| 406 | } | 413 | } |
| ... | @@ -420,7 +427,8 @@ export class BatchMutation< | ... | @@ -420,7 +427,8 @@ export class BatchMutation< |
| 420 | // Report any errors from refetch or callbacks | 427 | // Report any errors from refetch or callbacks |
| 421 | results.forEach((result) => { | 428 | results.forEach((result) => { |
| 422 | if (result.status === "rejected") { | 429 | if (result.status === "rejected") { |
| 423 | this.#client.reportError(result.reason); | 430 | const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; |
| 431 | this.#client.reportError(message, result.reason); | ||
| 424 | } | 432 | } |
| 425 | }); | 433 | }); |
| 426 | }).finally(() => { | 434 | }).finally(() => { |
| ... | @@ -458,7 +466,8 @@ export class BatchMutation< | ... | @@ -458,7 +466,8 @@ export class BatchMutation< |
| 458 | // Report any errors from refetch or callbacks | 466 | // Report any errors from refetch or callbacks |
| 459 | results.forEach((result) => { | 467 | results.forEach((result) => { |
| 460 | if (result.status === "rejected") { | 468 | if (result.status === "rejected") { |
| 461 | this.#client.reportError(result.reason); | 469 | const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; |
| 470 | this.#client.reportError(message, result.reason); | ||
| 462 | } | 471 | } |
| 463 | }); | 472 | }); |
| 464 | }).finally(() => { | 473 | }).finally(() => { |
src/client.ts+2-2| ... | @@ -21,7 +21,7 @@ export interface MutationClientOptions< | ... | @@ -21,7 +21,7 @@ export interface MutationClientOptions< |
| 21 | getOptimisticHelpers: ( | 21 | getOptimisticHelpers: ( |
| 22 | events: OptimisticEvents, | 22 | events: OptimisticEvents, |
| 23 | ) => OptimisticHelpers; | 23 | ) => OptimisticHelpers; |
| 24 | reportError: (error: unknown) => void; | 24 | reportError: (message: string, error: unknown) => void; |
| 25 | reportSuccess?: (message: string) => void; | 25 | reportSuccess?: (message: string) => void; |
| 26 | /** | 26 | /** |
| 27 | * Compare two values for deep equality. Used by BatchMutation to determine | 27 | * Compare two values for deep equality. Used by BatchMutation to determine |
| ... | @@ -42,7 +42,7 @@ export class MutationClient< | ... | @@ -42,7 +42,7 @@ export class MutationClient< |
| 42 | > { | 42 | > { |
| 43 | context: Context; | 43 | context: Context; |
| 44 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; | 44 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; |
| 45 | reportError: (error: unknown) => void; | 45 | reportError: (message: string, error: unknown) => void; |
| 46 | reportSuccess?: (message: string) => void; | 46 | reportSuccess?: (message: string) => void; |
| 47 | deepEquals: (a: unknown, b: unknown) => boolean; | 47 | deepEquals: (a: unknown, b: unknown) => boolean; |
| 48 | 48 |
src/queued.ts+9-3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | import type { MutationClientConfig } from "./client.ts"; | 2 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | 3 | import type { Mutation, MutationEvent } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 4 | 5 | ||
| 5 | /** | 6 | /** |
| 6 | * Argument to `defineMutation`. | 7 | * Argument to `defineMutation`. |
| ... | @@ -96,6 +97,7 @@ export class QueuedMutation< | ... | @@ -96,6 +97,7 @@ export class QueuedMutation< |
| 96 | #options: MutationOptions<Args, Result, Config>; | 97 | #options: MutationOptions<Args, Result, Config>; |
| 97 | #client: MutationClientFromConfig<Config>; | 98 | #client: MutationClientFromConfig<Config>; |
| 98 | #queues: Map<string, Channel<Args, Result>> = new Map(); | 99 | #queues: Map<string, Channel<Args, Result>> = new Map(); |
| 100 | client: MutationClientFromConfig<Config>; | ||
| 99 | 101 | ||
| 100 | constructor( | 102 | constructor( |
| 101 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | 103 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, |
| ... | @@ -103,6 +105,7 @@ export class QueuedMutation< | ... | @@ -103,6 +105,7 @@ export class QueuedMutation< |
| 103 | ) { | 105 | ) { |
| 104 | this.#options = options; | 106 | this.#options = options; |
| 105 | this.#client = client; | 107 | this.#client = client; |
| 108 | this.client = client; | ||
| 106 | } | 109 | } |
| 107 | 110 | ||
| 108 | key(args: Args) { | 111 | key(args: Args) { |
| ... | @@ -180,7 +183,8 @@ export class QueuedMutation< | ... | @@ -180,7 +183,8 @@ export class QueuedMutation< |
| 180 | this.#client.reportSuccess(message); | 183 | this.#client.reportSuccess(message); |
| 181 | } | 184 | } |
| 182 | }).catch((error) => { | 185 | }).catch((error) => { |
| 183 | this.#client.reportError(error); | 186 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; |
| 187 | this.#client.reportError(message, error); | ||
| 184 | }); | 188 | }); |
| 185 | } | 189 | } |
| 186 | 190 | ||
| ... | @@ -287,7 +291,8 @@ export class QueuedMutation< | ... | @@ -287,7 +291,8 @@ export class QueuedMutation< |
| 287 | // Report any errors from refetch or callbacks | 291 | // Report any errors from refetch or callbacks |
| 288 | results.forEach((result) => { | 292 | results.forEach((result) => { |
| 289 | if (result.status === "rejected") { | 293 | if (result.status === "rejected") { |
| 290 | this.#client.reportError(result.reason); | 294 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; |
| 295 | this.#client.reportError(message, result.reason); | ||
| 291 | } | 296 | } |
| 292 | }); | 297 | }); |
| 293 | }).finally(() => { | 298 | }).finally(() => { |
| ... | @@ -328,7 +333,8 @@ export class QueuedMutation< | ... | @@ -328,7 +333,8 @@ export class QueuedMutation< |
| 328 | // Report any errors from refetch or callbacks | 333 | // Report any errors from refetch or callbacks |
| 329 | results.forEach((result) => { | 334 | results.forEach((result) => { |
| 330 | if (result.status === "rejected") { | 335 | if (result.status === "rejected") { |
| 331 | this.#client.reportError(result.reason); | 336 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; |
| 337 | this.#client.reportError(message, result.reason); | ||
| 332 | } | 338 | } |
| 333 | }); | 339 | }); |
| 334 | }).finally(() => { | 340 | }).finally(() => { |
src/react.tsx+45-12| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | useEffect, | 7 | useEffect, |
| 8 | useState, | 8 | useState, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 10 | import type { Mutation } from "./types.ts"; | 11 | import type { Mutation } from "./types.ts"; |
| 11 | 12 | ||
| 12 | /** | 13 | /** |
| ... | @@ -46,6 +47,7 @@ export interface UseMutateSuccess<Result> { | ... | @@ -46,6 +47,7 @@ export interface UseMutateSuccess<Result> { |
| 46 | status: "success"; | 47 | status: "success"; |
| 47 | result: Result; | 48 | result: Result; |
| 48 | error: undefined; | 49 | error: undefined; |
| 50 | errorMessage: undefined; | ||
| 49 | /** `true` when a `mutate` function is currently running. */ | 51 | /** `true` when a `mutate` function is currently running. */ |
| 50 | isMutating: false; | 52 | isMutating: false; |
| 51 | /** `true` when a loading indicator should be shown. */ | 53 | /** `true` when a loading indicator should be shown. */ |
| ... | @@ -61,6 +63,8 @@ export interface UseMutateError { | ... | @@ -61,6 +63,8 @@ export interface UseMutateError { |
| 61 | status: "error"; | 63 | status: "error"; |
| 62 | result: undefined; | 64 | result: undefined; |
| 63 | error: unknown; | 65 | error: unknown; |
| 66 | /** User-friendly in this format: `Failed to {action}: {details}` */ | ||
| 67 | errorMessage: string; | ||
| 64 | /** `true` when a `mutate` function is currently running. */ | 68 | /** `true` when a `mutate` function is currently running. */ |
| 65 | isMutating: false; | 69 | isMutating: false; |
| 66 | /** `true` when a loading indicator should be shown. */ | 70 | /** `true` when a loading indicator should be shown. */ |
| ... | @@ -76,6 +80,7 @@ export interface UseMutateIdle { | ... | @@ -76,6 +80,7 @@ export interface UseMutateIdle { |
| 76 | status: "idle" | "mutating"; | 80 | status: "idle" | "mutating"; |
| 77 | result: undefined; | 81 | result: undefined; |
| 78 | error: undefined; | 82 | error: undefined; |
| 83 | errorMessage: undefined; | ||
| 79 | /** `true` when a `mutate` function is currently running. */ | 84 | /** `true` when a `mutate` function is currently running. */ |
| 80 | isMutating: boolean; | 85 | isMutating: boolean; |
| 81 | /** `true` when a loading indicator should be shown. */ | 86 | /** `true` when a loading indicator should be shown. */ |
| ... | @@ -91,12 +96,13 @@ export interface UseMutateIdle { | ... | @@ -91,12 +96,13 @@ export interface UseMutateIdle { |
| 91 | type AnyMutationState<Result> = | 96 | type AnyMutationState<Result> = |
| 92 | & Omit< | 97 | & Omit< |
| 93 | UseMutateIdle, | 98 | UseMutateIdle, |
| 94 | "status" | "result" | "error" | "isSuccess" | "isError" | 99 | "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" |
| 95 | > | 100 | > |
| 96 | & { | 101 | & { |
| 97 | status: "idle" | "mutating" | "error" | "success"; | 102 | status: "idle" | "mutating" | "error" | "success"; |
| 98 | result: undefined | Result; | 103 | result: undefined | Result; |
| 99 | error: undefined | unknown; | 104 | error: undefined | unknown; |
| 105 | errorMessage: undefined | string; | ||
| 100 | isSuccess: boolean; | 106 | isSuccess: boolean; |
| 101 | isError: boolean; | 107 | isError: boolean; |
| 102 | }; | 108 | }; |
| ... | @@ -106,6 +112,7 @@ function initialState() { | ... | @@ -106,6 +112,7 @@ function initialState() { |
| 106 | status: "idle", | 112 | status: "idle", |
| 107 | result: undefined, | 113 | result: undefined, |
| 108 | error: undefined, | 114 | error: undefined, |
| 115 | errorMessage: undefined, | ||
| 109 | isMutating: false, | 116 | isMutating: false, |
| 110 | isPending: false, | 117 | isPending: false, |
| 111 | isSuccess: false, | 118 | isSuccess: false, |
| ... | @@ -119,6 +126,7 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -119,6 +126,7 @@ class Observer<Args extends unknown[], Result> { |
| 119 | mutation: Mutation<Args, Result> | null = null; | 126 | mutation: Mutation<Args, Result> | null = null; |
| 120 | unsubscribe: (() => void) | null = null; | 127 | unsubscribe: (() => void) | null = null; |
| 121 | currentKey: string | null = null; | 128 | currentKey: string | null = null; |
| 129 | currentArgs: Args | null = null; | ||
| 122 | 130 | ||
| 123 | constructor(setRerender: (fn: number) => void) { | 131 | constructor(setRerender: (fn: number) => void) { |
| 124 | this.setRerender = setRerender; | 132 | this.setRerender = setRerender; |
| ... | @@ -147,10 +155,20 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -147,10 +155,20 @@ class Observer<Args extends unknown[], Result> { |
| 147 | this.state = initialState(); | 155 | this.state = initialState(); |
| 148 | } | 156 | } |
| 149 | 157 | ||
| 158 | computeErrorMessage(error: unknown): string | undefined { | ||
| 159 | if (!error) return undefined; | ||
| 160 | const mutation = this.mutation; | ||
| 161 | if (!mutation || !this.currentArgs) return errMessage(error); | ||
| 162 | return `Failed to ${mutation.describe(...this.currentArgs)}: ${ | ||
| 163 | errMessage(error) | ||
| 164 | }`; | ||
| 165 | } | ||
| 166 | |||
| 150 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ | 167 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ |
| 151 | run(...args: Args) { | 168 | run(...args: Args) { |
| 152 | const mutation = self.mutation; | 169 | const mutation = self.mutation; |
| 153 | if (!mutation) return; | 170 | if (!mutation) return; |
| 171 | self.currentArgs = args; | ||
| 154 | const key = mutation.key(args); | 172 | const key = mutation.key(args); |
| 155 | if (key !== self.currentKey) { | 173 | if (key !== self.currentKey) { |
| 156 | self.currentKey = key; | 174 | self.currentKey = key; |
| ... | @@ -178,6 +196,7 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -178,6 +196,7 @@ class Observer<Args extends unknown[], Result> { |
| 178 | ? "mutating" | 196 | ? "mutating" |
| 179 | : "idle", | 197 | : "idle", |
| 180 | error: error ?? undefined, | 198 | error: error ?? undefined, |
| 199 | errorMessage: self.computeErrorMessage(error ?? undefined), | ||
| 181 | result: result ?? undefined, | 200 | result: result ?? undefined, |
| 182 | isMutating: status === "mutating", | 201 | isMutating: status === "mutating", |
| 183 | isPending: status === "mutating" || status === "refetching", | 202 | isPending: status === "mutating" || status === "refetching", |
| ... | @@ -189,19 +208,28 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -189,19 +208,28 @@ class Observer<Args extends unknown[], Result> { |
| 189 | }, | 208 | }, |
| 190 | ); | 209 | ); |
| 191 | } | 210 | } |
| 192 | // use global error/success handling if this usage of the hook doesn't check for | 211 | // Use global error/success handling if this usage of the hook doesn't check for |
| 193 | // errors or success. This makes it act pretty awesome in terms of defaults. | 212 | // errors or success. This makes it act pretty awesome in terms of defaults. |
| 194 | // You don't have to worry about the errors/successes, they'll surface exactly once. | 213 | // You don't have to worry about result UI, they'll surface exactly once. |
| 195 | if ( | 214 | const watchesError = self.watched.has("isError") || |
| 196 | self.watched.has("isError") || self.watched.has("error") || | 215 | self.watched.has("error") || self.watched.has("errorMessage"); |
| 197 | self.watched.has("isSuccess") || self.watched.has("result") | 216 | const watchesSuccess = self.watched.has("isSuccess") || |
| 198 | ) { | 217 | self.watched.has("result"); |
| 199 | mutation.runAndReturn(...args).catch(() => { | 218 | mutation.runAndReturn(...args) |
| 200 | // caught in event listener | 219 | .then((result) => { |
| 220 | if (!watchesSuccess && mutation.describeResult) { | ||
| 221 | const message = mutation.describeResult(args, result); | ||
| 222 | if (message && mutation.client.reportSuccess) { | ||
| 223 | mutation.client.reportSuccess(message); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | }) | ||
| 227 | .catch((err) => { | ||
| 228 | if (!watchesError) { | ||
| 229 | const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; | ||
| 230 | mutation.client.reportError(message, err); | ||
| 231 | } | ||
| 201 | }); | 232 | }); |
| 202 | } else { | ||
| 203 | mutation.run(...args); | ||
| 204 | } | ||
| 205 | }, | 233 | }, |
| 206 | clear() { | 234 | clear() { |
| 207 | self.setState({ | 235 | self.setState({ |
| ... | @@ -211,6 +239,7 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -211,6 +239,7 @@ class Observer<Args extends unknown[], Result> { |
| 211 | isError: false, | 239 | isError: false, |
| 212 | isSuccess: false, | 240 | isSuccess: false, |
| 213 | error: undefined, | 241 | error: undefined, |
| 242 | errorMessage: undefined, | ||
| 214 | result: undefined, | 243 | result: undefined, |
| 215 | }); | 244 | }); |
| 216 | }, | 245 | }, |
| ... | @@ -226,6 +255,10 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -226,6 +255,10 @@ class Observer<Args extends unknown[], Result> { |
| 226 | self.watched.add("error"); | 255 | self.watched.add("error"); |
| 227 | return self.state.error; | 256 | return self.state.error; |
| 228 | }, | 257 | }, |
| 258 | get errorMessage() { | ||
| 259 | self.watched.add("errorMessage"); | ||
| 260 | return self.state.errorMessage; | ||
| 261 | }, | ||
| 229 | get isMutating() { | 262 | get isMutating() { |
| 230 | self.watched.add("isMutating"); | 263 | self.watched.add("isMutating"); |
| 231 | return self.state.isMutating; | 264 | return self.state.isMutating; |
src/tanstack-query.ts+25-21| ... | @@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers { |
| 307 | } | 307 | } |
| 308 | 308 | ||
| 309 | /** | 309 | /** |
| 310 | * Remove items from an array that match a predicate. | 310 | * Remove items from an array that match `filter`. |
| 311 | * If the query or path doesn't exist, the updater is skipped. | 311 | * If the query or path doesn't exist, the updater is skipped. |
| 312 | */ | 312 | */ |
| 313 | objArrayRemove< | 313 | objArrayRemove< |
| ... | @@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers { |
| 316 | >( | 316 | >( |
| 317 | queryKey: QueryKeyAndFn<Data>, | 317 | queryKey: QueryKeyAndFn<Data>, |
| 318 | path: Path, | 318 | path: Path, |
| 319 | predicate: ( | 319 | filter: ( |
| 320 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | 320 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, |
| 321 | index: number, | 321 | index: number, |
| 322 | ) => boolean, | 322 | ) => boolean, |
| ... | @@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers { |
| 326 | const { value: original, exists } = getPath(prev, path); | 326 | const { value: original, exists } = getPath(prev, path); |
| 327 | if (!exists || !Array.isArray(original)) return; | 327 | if (!exists || !Array.isArray(original)) return; |
| 328 | 328 | ||
| 329 | const newArray = original.filter((item, index) => !predicate(item, index)); | 329 | const newArray = original.filter((item, index) => !filter(item, index)); |
| 330 | this.#set( | 330 | this.#set( |
| 331 | queryKey, | 331 | queryKey, |
| 332 | (obj) => obj ? setPath(obj, path, newArray as any) : obj, | 332 | (obj) => obj ? setPath(obj, path, newArray as any) : obj, |
| ... | @@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers { |
| 347 | >( | 347 | >( |
| 348 | queryKey: QueryKeyAndFn<Data>, | 348 | queryKey: QueryKeyAndFn<Data>, |
| 349 | path: Path, | 349 | path: Path, |
| 350 | predicate: ( | 350 | { filter, update }: { |
| 351 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | 351 | filter: ( |
| 352 | index: number, | 352 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, |
| 353 | ) => boolean, | 353 | index: number, |
| 354 | updater: ( | 354 | ) => boolean; |
| 355 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | 355 | update: ( |
| 356 | ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never, | 356 | item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never, |
| 357 | ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never; | ||
| 358 | }, | ||
| 357 | ) { | 359 | ) { |
| 358 | const prev = this.#get(queryKey); | 360 | const prev = this.#get(queryKey); |
| 359 | if (!prev) return; | 361 | if (!prev) return; |
| ... | @@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers { |
| 361 | if (!exists || !Array.isArray(original)) return; | 363 | if (!exists || !Array.isArray(original)) return; |
| 362 | 364 | ||
| 363 | const newArray = original.map((item, index) => | 365 | const newArray = original.map((item, index) => |
| 364 | predicate(item, index) ? updater(item) : item | 366 | filter(item, index) ? update(item) : item |
| 365 | ); | 367 | ); |
| 366 | this.#set( | 368 | this.#set( |
| 367 | queryKey, | 369 | queryKey, |
| ... | @@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers { |
| 444 | } | 446 | } |
| 445 | 447 | ||
| 446 | /** | 448 | /** |
| 447 | * Remove items from an array that match a predicate. | 449 | * Remove items from an array that match a `filter`. |
| 448 | * If the query or path doesn't exist, the updater is skipped. | 450 | * If the query or path doesn't exist, the updater is skipped. |
| 449 | */ | 451 | */ |
| 450 | arrayRemove<Data>( | 452 | arrayRemove<Data>( |
| 451 | queryKey: QueryKeyAndFn<Data[]>, | 453 | queryKey: QueryKeyAndFn<Data[]>, |
| 452 | predicate: ( | 454 | filter: ( |
| 453 | item: Data, | 455 | item: Data, |
| 454 | index: number, | 456 | index: number, |
| 455 | ) => boolean, | 457 | ) => boolean, |
| ... | @@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers { |
| 457 | const prev = this.#get(queryKey); | 459 | const prev = this.#get(queryKey); |
| 458 | if (!prev || !Array.isArray(prev)) return; | 460 | if (!prev || !Array.isArray(prev)) return; |
| 459 | 461 | ||
| 460 | const newArray = prev.filter((item, index) => !predicate(item, index)); | 462 | const newArray = prev.filter((item, index) => !filter(item, index)); |
| 461 | this.#set(queryKey, newArray); | 463 | this.#set(queryKey, newArray); |
| 462 | this.#onRestore(() => { | 464 | this.#onRestore(() => { |
| 463 | // TODO: splice items back in case original changed | 465 | // TODO: splice items back in case original changed |
| ... | @@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers { | ... | @@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers { |
| 466 | } | 468 | } |
| 467 | 469 | ||
| 468 | /** | 470 | /** |
| 469 | * Update items in an array that match a predicate. | 471 | * Update items in an array that match a `filter`. |
| 470 | * If the query or path doesn't exist, the updater is skipped. | 472 | * If the query or path doesn't exist, the updater is skipped. |
| 471 | */ | 473 | */ |
| 472 | arrayUpdate<Data>( | 474 | arrayUpdate<Data>( |
| 473 | queryKey: QueryKeyAndFn<Data[]>, | 475 | queryKey: QueryKeyAndFn<Data[]>, |
| 474 | predicate: ( | 476 | { |
| 475 | item: Data, | 477 | filter, |
| 476 | index: number, | 478 | update, |
| 477 | ) => boolean, | 479 | }: { |
| 478 | updater: (item: Data) => Data, | 480 | filter?: (item: Data, index: number) => boolean; |
| 481 | update: (item: Data) => Data; | ||
| 482 | }, | ||
| 479 | ) { | 483 | ) { |
| 480 | const prev = this.#get(queryKey); | 484 | const prev = this.#get(queryKey); |
| 481 | if (!prev || !Array.isArray(prev)) return; | 485 | if (!prev || !Array.isArray(prev)) return; |
| 482 | 486 | ||
| 483 | const newArray = prev.map((item, index) => | 487 | const newArray = prev.map((item, index) => |
| 484 | predicate(item, index) ? updater(item) : item | 488 | (filter ? filter(item, index) : true) ? update(item) : item |
| 485 | ); | 489 | ); |
| 486 | this.#set(queryKey, newArray); | 490 | this.#set(queryKey, newArray); |
| 487 | this.#onRestore(() => { | 491 | this.#onRestore(() => { |
src/types.ts+4| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import type { MutationClient } from "./client.ts"; | ||
| 2 | |||
| 1 | export interface Mutation<Args extends unknown[], Result> { | 3 | export interface Mutation<Args extends unknown[], Result> { |
| 2 | /** Calling the mutation. Errors are turned into UI toasts. */ | 4 | /** Calling the mutation. Errors are turned into UI toasts. */ |
| 3 | run(...args: Args): void; | 5 | run(...args: Args): void; |
| ... | @@ -12,6 +14,8 @@ export interface Mutation<Args extends unknown[], Result> { | ... | @@ -12,6 +14,8 @@ export interface Mutation<Args extends unknown[], Result> { |
| 12 | cb: (update: MutationEvent<Result>) => void, | 14 | cb: (update: MutationEvent<Result>) => void, |
| 13 | ): () => void; | 15 | ): () => void; |
| 14 | describe(...args: Args): string; | 16 | describe(...args: Args): string; |
| 17 | describeResult?: (args: Args, result: Result) => string | undefined; | ||
| 18 | client: MutationClient<object, object>; | ||
| 15 | } | 19 | } |
| 16 | 20 | ||
| 17 | export interface MutationEvent<Result> { | 21 | export interface MutationEvent<Result> { |
test/batch.test.ts+3-3| ... | @@ -31,7 +31,7 @@ function createTestClient() { | ... | @@ -31,7 +31,7 @@ function createTestClient() { |
| 31 | }, | 31 | }, |
| 32 | }; | 32 | }; |
| 33 | }, | 33 | }, |
| 34 | reportError(error) { | 34 | reportError(message, error) { |
| 35 | errors.push(error); | 35 | errors.push(error); |
| 36 | }, | 36 | }, |
| 37 | }); | 37 | }); |
| ... | @@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { | ... | @@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => { |
| 472 | }, | 472 | }, |
| 473 | }; | 473 | }; |
| 474 | }, | 474 | }, |
| 475 | reportError(error) { | 475 | reportError(message, error) { |
| 476 | errors.push(error); | 476 | errors.push(error); |
| 477 | }, | 477 | }, |
| 478 | }); | 478 | }); |
| ... | @@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => { | ... | @@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => { |
| 518 | }, | 518 | }, |
| 519 | }; | 519 | }; |
| 520 | }, | 520 | }, |
| 521 | reportError(error) { | 521 | reportError(message, error) { |
| 522 | errors.push(error); | 522 | errors.push(error); |
| 523 | }, | 523 | }, |
| 524 | deepEquals(a, b) { | 524 | deepEquals(a, b) { |
test/object-path-types.test.ts+6-10| ... | @@ -3,16 +3,13 @@ | ... | @@ -3,16 +3,13 @@ |
| 3 | * These tests verify that TypeScript types work correctly at compile time | 3 | * These tests verify that TypeScript types work correctly at compile time |
| 4 | */ | 4 | */ |
| 5 | 5 | ||
| 6 | import type { | 6 | import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; |
| 7 | AllObjectPaths, | ||
| 8 | GetObjectPath, | ||
| 9 | } from "../src/object-path.ts"; | ||
| 10 | 7 | ||
| 11 | // Type testing utilities | 8 | // Type testing utilities |
| 12 | type Expect<T extends true> = T; | 9 | type Expect<T extends true> = T; |
| 13 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y | 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends |
| 14 | ? 1 | 11 | <T>() => T extends Y ? 1 |
| 15 | : 2 ? true | 12 | : 2 ? true |
| 16 | : false; | 13 | : false; |
| 17 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; | 14 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; |
| 18 | type IsAny<T> = 0 extends 1 & T ? true : false; | 15 | type IsAny<T> = 0 extends 1 & T ? true : false; |
| ... | @@ -348,7 +345,7 @@ type ArrayRemoveSignature< | ... | @@ -348,7 +345,7 @@ type ArrayRemoveSignature< |
| 348 | Data extends object, | 345 | Data extends object, |
| 349 | Path extends AllObjectPaths<Data>, | 346 | Path extends AllObjectPaths<Data>, |
| 350 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] | 347 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] |
| 351 | ? (path: Path, predicate: (item: T, index: number) => boolean) => void | 348 | ? (path: Path, filter: (item: T, index: number) => boolean) => void |
| 352 | : never; | 349 | : never; |
| 353 | 350 | ||
| 354 | declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>; | 351 | declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>; |
| ... | @@ -379,8 +376,7 @@ type IncrementNameTest = Expect< | ... | @@ -379,8 +376,7 @@ type IncrementNameTest = Expect< |
| 379 | type ToggleSignature< | 376 | type ToggleSignature< |
| 380 | Data extends object, | 377 | Data extends object, |
| 381 | Path extends AllObjectPaths<Data>, | 378 | Path extends AllObjectPaths<Data>, |
| 382 | > = GetObjectPath<Data, Path> extends boolean | 379 | > = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void |
| 383 | ? (path: Path) => void | ||
| 384 | : never; | 380 | : never; |
| 385 | 381 | ||
| 386 | declare const toggle: ToggleSignature<TestData, ["active"]>; | 382 | declare const toggle: ToggleSignature<TestData, ["active"]>; |
test/queued.test.ts+1-1| ... | @@ -16,7 +16,7 @@ function createTestClient() { | ... | @@ -16,7 +16,7 @@ function createTestClient() { |
| 16 | }, | 16 | }, |
| 17 | }; | 17 | }; |
| 18 | }, | 18 | }, |
| 19 | reportError(error) { | 19 | reportError(message, error) { |
| 20 | errors.push(error); | 20 | errors.push(error); |
| 21 | }, | 21 | }, |
| 22 | }); | 22 | }); |
test/tanstack-query-helpers.test.ts+12-6| ... | @@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => { | ... | @@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => { |
| 586 | helpers.objArrayUpdate( | 586 | helpers.objArrayUpdate( |
| 587 | queryTest, | 587 | queryTest, |
| 588 | ["items"], | 588 | ["items"], |
| 589 | (item) => item.id === 2, | 589 | { |
| 590 | (item) => ({ ...item, label: "UPDATED" }), | 590 | filter: (item) => item.id === 2, |
| 591 | update: (item) => ({ ...item, label: "UPDATED" }), | ||
| 592 | }, | ||
| 591 | ); | 593 | ); |
| 592 | 594 | ||
| 593 | const result = client.getQueryData<TestData>(queryTest.queryKey); | 595 | const result = client.getQueryData<TestData>(queryTest.queryKey); |
| ... | @@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => { | ... | @@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => { |
| 611 | helpers.objArrayUpdate( | 613 | helpers.objArrayUpdate( |
| 612 | queryTest, | 614 | queryTest, |
| 613 | ["items"], | 615 | ["items"], |
| 614 | (item) => item.id > 1, | 616 | { |
| 615 | (item) => ({ ...item, label: item.label.toUpperCase() }), | 617 | filter: (item) => item.id > 1, |
| 618 | update: (item) => ({ ...item, label: item.label.toUpperCase() }), | ||
| 619 | }, | ||
| 616 | ); | 620 | ); |
| 617 | 621 | ||
| 618 | const result = client.getQueryData<TestData>(queryTest.queryKey); | 622 | const result = client.getQueryData<TestData>(queryTest.queryKey); |
| ... | @@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => { | ... | @@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => { |
| 632 | helpers.objArrayUpdate( | 636 | helpers.objArrayUpdate( |
| 633 | queryTest, | 637 | queryTest, |
| 634 | ["items"], | 638 | ["items"], |
| 635 | (_item, index) => index === 0, | 639 | { |
| 636 | (item) => ({ ...item, label: "FIRST" }), | 640 | filter: (_item, index) => index === 0, |
| 641 | update: (item) => ({ ...item, label: "FIRST" }), | ||
| 642 | }, | ||
| 637 | ); | 643 | ); |
| 638 | 644 | ||
| 639 | const result = client.getQueryData<TestData>(queryTest.queryKey); | 645 | const result = client.getQueryData<TestData>(queryTest.queryKey); |