authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 15:24:00-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:51:56-08:00
log331a3e7e7454101d1933ba3d28996ad554a0bf6a
tree8160eeedbf1b0891674630fb694e639fed662619
parentde7e1ab0f88b02444249d69a59c5fed7c981b6c9
signaturelock-open Commit is signed but in an unrecognized format.

feat: some more features


13 files changed, 132 insertions(+), 66 deletions(-)

jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.1",
3 "version": "1.0.0-beta.2",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query.ts": "./src/tanstack-query.ts",
package-lock.json+9-2
......@@ -1,14 +1,15 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "0.0.0",
3 "version": "0.1.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "@clo/react-mutation",
9 "version": "0.0.0",
9 "version": "0.1.0",
1010 "license": "ISC",
1111 "dependencies": {
12 "@clo/lib": "npm:@jsr/clo__lib@^3.0.0",
1213 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
1314 },
1415 "devDependencies": {
......@@ -315,6 +316,12 @@
315316 "node": ">=6.9.0"
316317 }
317318 },
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 },
318325 "node_modules/@esbuild/aix-ppc64": {
319326 "version": "0.27.2",
320327 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
package.json+1
......@@ -13,6 +13,7 @@
1313 "check": "tsc --noEmit"
1414 },
1515 "dependencies": {
16 "@clo/lib": "npm:@jsr/clo__lib@^3.0.0",
1617 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
1718 },
1819 "devDependencies": {
src/batch.ts+14-5
......@@ -1,6 +1,7 @@
11import type { MutationClient, MutationClientFromConfig } from "./client.ts";
22import type { MutationClientConfig } from "./client.ts";
33import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
45
56export interface BatchMutationOptions<
67 Args extends unknown[],
......@@ -124,6 +125,7 @@ export class BatchMutation<
124125 #options: BatchMutationOptions<Args, Result, Optimistic, Config>;
125126 #client: MutationClientFromConfig<Config>;
126127 #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map();
128 client: MutationClientFromConfig<Config>;
127129
128130 constructor(
129131 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
......@@ -131,6 +133,7 @@ export class BatchMutation<
131133 ) {
132134 this.#options = options;
133135 this.#client = client;
136 this.client = client;
134137 }
135138
136139 key(args: Args): string {
......@@ -212,7 +215,10 @@ export class BatchMutation<
212215 return describe;
213216 }
214217
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 {
216222 const { describeResult } = this.#options;
217223 if (describeResult === null || describeResult === undefined) return undefined;
218224 return typeof describeResult === "function"
......@@ -229,7 +235,8 @@ export class BatchMutation<
229235 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
230236 run(...args: Args): void {
231237 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);
233240 });
234241 }
235242
......@@ -400,7 +407,7 @@ export class BatchMutation<
400407 // Report success globally if any of the pending items requested it
401408 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
402409 if (shouldReportSuccess) {
403 const message = this.describeResult(firstArgs, initial, current, result);
410 const message = this.#describeResult(firstArgs, initial, current, result);
404411 if (message && this.#client.reportSuccess) {
405412 this.#client.reportSuccess(message);
406413 }
......@@ -420,7 +427,8 @@ export class BatchMutation<
420427 // Report any errors from refetch or callbacks
421428 results.forEach((result) => {
422429 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);
424432 }
425433 });
426434 }).finally(() => {
......@@ -458,7 +466,8 @@ export class BatchMutation<
458466 // Report any errors from refetch or callbacks
459467 results.forEach((result) => {
460468 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);
462471 }
463472 });
464473 }).finally(() => {
src/client.ts+2-2
......@@ -21,7 +21,7 @@ export interface MutationClientOptions<
2121 getOptimisticHelpers: (
2222 events: OptimisticEvents,
2323 ) => OptimisticHelpers;
24 reportError: (error: unknown) => void;
24 reportError: (message: string, error: unknown) => void;
2525 reportSuccess?: (message: string) => void;
2626 /**
2727 * Compare two values for deep equality. Used by BatchMutation to determine
......@@ -42,7 +42,7 @@ export class MutationClient<
4242> {
4343 context: Context;
4444 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
45 reportError: (error: unknown) => void;
45 reportError: (message: string, error: unknown) => void;
4646 reportSuccess?: (message: string) => void;
4747 deepEquals: (a: unknown, b: unknown) => boolean;
4848
src/queued.ts+9-3
......@@ -1,6 +1,7 @@
11import type { MutationClient, MutationClientFromConfig } from "./client.ts";
22import type { MutationClientConfig } from "./client.ts";
33import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
45
56/**
67 * Argument to `defineMutation`.
......@@ -96,6 +97,7 @@ export class QueuedMutation<
9697 #options: MutationOptions<Args, Result, Config>;
9798 #client: MutationClientFromConfig<Config>;
9899 #queues: Map<string, Channel<Args, Result>> = new Map();
100 client: MutationClientFromConfig<Config>;
99101
100102 constructor(
101103 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
......@@ -103,6 +105,7 @@ export class QueuedMutation<
103105 ) {
104106 this.#options = options;
105107 this.#client = client;
108 this.client = client;
106109 }
107110
108111 key(args: Args) {
......@@ -180,7 +183,8 @@ export class QueuedMutation<
180183 this.#client.reportSuccess(message);
181184 }
182185 }).catch((error) => {
183 this.#client.reportError(error);
186 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
187 this.#client.reportError(message, error);
184188 });
185189 }
186190
......@@ -287,7 +291,8 @@ export class QueuedMutation<
287291 // Report any errors from refetch or callbacks
288292 results.forEach((result) => {
289293 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);
291296 }
292297 });
293298 }).finally(() => {
......@@ -328,7 +333,8 @@ export class QueuedMutation<
328333 // Report any errors from refetch or callbacks
329334 results.forEach((result) => {
330335 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);
332338 }
333339 });
334340 }).finally(() => {
src/react.tsx+45-12
......@@ -7,6 +7,7 @@ import {
77 useEffect,
88 useState,
99} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
1011import type { Mutation } from "./types.ts";
1112
1213/**
......@@ -46,6 +47,7 @@ export interface UseMutateSuccess<Result> {
4647 status: "success";
4748 result: Result;
4849 error: undefined;
50 errorMessage: undefined;
4951 /** `true` when a `mutate` function is currently running. */
5052 isMutating: false;
5153 /** `true` when a loading indicator should be shown. */
......@@ -61,6 +63,8 @@ export interface UseMutateError {
6163 status: "error";
6264 result: undefined;
6365 error: unknown;
66 /** User-friendly in this format: `Failed to {action}: {details}` */
67 errorMessage: string;
6468 /** `true` when a `mutate` function is currently running. */
6569 isMutating: false;
6670 /** `true` when a loading indicator should be shown. */
......@@ -76,6 +80,7 @@ export interface UseMutateIdle {
7680 status: "idle" | "mutating";
7781 result: undefined;
7882 error: undefined;
83 errorMessage: undefined;
7984 /** `true` when a `mutate` function is currently running. */
8085 isMutating: boolean;
8186 /** `true` when a loading indicator should be shown. */
......@@ -91,12 +96,13 @@ export interface UseMutateIdle {
9196type AnyMutationState<Result> =
9297 & Omit<
9398 UseMutateIdle,
94 "status" | "result" | "error" | "isSuccess" | "isError"
99 "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage"
95100 >
96101 & {
97102 status: "idle" | "mutating" | "error" | "success";
98103 result: undefined | Result;
99104 error: undefined | unknown;
105 errorMessage: undefined | string;
100106 isSuccess: boolean;
101107 isError: boolean;
102108 };
......@@ -106,6 +112,7 @@ function initialState() {
106112 status: "idle",
107113 result: undefined,
108114 error: undefined,
115 errorMessage: undefined,
109116 isMutating: false,
110117 isPending: false,
111118 isSuccess: false,
......@@ -119,6 +126,7 @@ class Observer<Args extends unknown[], Result> {
119126 mutation: Mutation<Args, Result> | null = null;
120127 unsubscribe: (() => void) | null = null;
121128 currentKey: string | null = null;
129 currentArgs: Args | null = null;
122130
123131 constructor(setRerender: (fn: number) => void) {
124132 this.setRerender = setRerender;
......@@ -147,10 +155,20 @@ class Observer<Args extends unknown[], Result> {
147155 this.state = initialState();
148156 }
149157
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
150167 binding: UseMutateResult<Args, Result> = ((self: this) => ({
151168 run(...args: Args) {
152169 const mutation = self.mutation;
153170 if (!mutation) return;
171 self.currentArgs = args;
154172 const key = mutation.key(args);
155173 if (key !== self.currentKey) {
156174 self.currentKey = key;
......@@ -178,6 +196,7 @@ class Observer<Args extends unknown[], Result> {
178196 ? "mutating"
179197 : "idle",
180198 error: error ?? undefined,
199 errorMessage: self.computeErrorMessage(error ?? undefined),
181200 result: result ?? undefined,
182201 isMutating: status === "mutating",
183202 isPending: status === "mutating" || status === "refetching",
......@@ -189,19 +208,28 @@ class Observer<Args extends unknown[], Result> {
189208 },
190209 );
191210 }
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
193212 // 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.
195 if (
196 self.watched.has("isError") || self.watched.has("error") ||
197 self.watched.has("isSuccess") || self.watched.has("result")
198 ) {
199 mutation.runAndReturn(...args).catch(() => {
200 // caught in event listener
213 // You don't have to worry about result UI, they'll surface exactly once.
214 const watchesError = self.watched.has("isError") ||
215 self.watched.has("error") || self.watched.has("errorMessage");
216 const watchesSuccess = self.watched.has("isSuccess") ||
217 self.watched.has("result");
218 mutation.runAndReturn(...args)
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 }
201232 });
202 } else {
203 mutation.run(...args);
204 }
205233 },
206234 clear() {
207235 self.setState({
......@@ -211,6 +239,7 @@ class Observer<Args extends unknown[], Result> {
211239 isError: false,
212240 isSuccess: false,
213241 error: undefined,
242 errorMessage: undefined,
214243 result: undefined,
215244 });
216245 },
......@@ -226,6 +255,10 @@ class Observer<Args extends unknown[], Result> {
226255 self.watched.add("error");
227256 return self.state.error;
228257 },
258 get errorMessage() {
259 self.watched.add("errorMessage");
260 return self.state.errorMessage;
261 },
229262 get isMutating() {
230263 self.watched.add("isMutating");
231264 return self.state.isMutating;
src/tanstack-query.ts+25-21
......@@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers {
307307 }
308308
309309 /**
310 * Remove items from an array that match a predicate.
310 * Remove items from an array that match `filter`.
311311 * If the query or path doesn't exist, the updater is skipped.
312312 */
313313 objArrayRemove<
......@@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers {
316316 >(
317317 queryKey: QueryKeyAndFn<Data>,
318318 path: Path,
319 predicate: (
319 filter: (
320320 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
321321 index: number,
322322 ) => boolean,
......@@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers {
326326 const { value: original, exists } = getPath(prev, path);
327327 if (!exists || !Array.isArray(original)) return;
328328
329 const newArray = original.filter((item, index) => !predicate(item, index));
329 const newArray = original.filter((item, index) => !filter(item, index));
330330 this.#set(
331331 queryKey,
332332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
......@@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers {
347347 >(
348348 queryKey: QueryKeyAndFn<Data>,
349349 path: Path,
350 predicate: (
351 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
352 index: number,
353 ) => boolean,
354 updater: (
355 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
356 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
350 { filter, update }: {
351 filter: (
352 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
353 index: number,
354 ) => boolean;
355 update: (
356 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
357 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
358 },
357359 ) {
358360 const prev = this.#get(queryKey);
359361 if (!prev) return;
......@@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers {
361363 if (!exists || !Array.isArray(original)) return;
362364
363365 const newArray = original.map((item, index) =>
364 predicate(item, index) ? updater(item) : item
366 filter(item, index) ? update(item) : item
365367 );
366368 this.#set(
367369 queryKey,
......@@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers {
444446 }
445447
446448 /**
447 * Remove items from an array that match a predicate.
449 * Remove items from an array that match a `filter`.
448450 * If the query or path doesn't exist, the updater is skipped.
449451 */
450452 arrayRemove<Data>(
451453 queryKey: QueryKeyAndFn<Data[]>,
452 predicate: (
454 filter: (
453455 item: Data,
454456 index: number,
455457 ) => boolean,
......@@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers {
457459 const prev = this.#get(queryKey);
458460 if (!prev || !Array.isArray(prev)) return;
459461
460 const newArray = prev.filter((item, index) => !predicate(item, index));
462 const newArray = prev.filter((item, index) => !filter(item, index));
461463 this.#set(queryKey, newArray);
462464 this.#onRestore(() => {
463465 // TODO: splice items back in case original changed
......@@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers {
466468 }
467469
468470 /**
469 * Update items in an array that match a predicate.
471 * Update items in an array that match a `filter`.
470472 * If the query or path doesn't exist, the updater is skipped.
471473 */
472474 arrayUpdate<Data>(
473475 queryKey: QueryKeyAndFn<Data[]>,
474 predicate: (
475 item: Data,
476 index: number,
477 ) => boolean,
478 updater: (item: Data) => Data,
476 {
477 filter,
478 update,
479 }: {
480 filter?: (item: Data, index: number) => boolean;
481 update: (item: Data) => Data;
482 },
479483 ) {
480484 const prev = this.#get(queryKey);
481485 if (!prev || !Array.isArray(prev)) return;
482486
483487 const newArray = prev.map((item, index) =>
484 predicate(item, index) ? updater(item) : item
488 (filter ? filter(item, index) : true) ? update(item) : item
485489 );
486490 this.#set(queryKey, newArray);
487491 this.#onRestore(() => {
src/types.ts+4
......@@ -1,3 +1,5 @@
1import type { MutationClient } from "./client.ts";
2
13export interface Mutation<Args extends unknown[], Result> {
24 /** Calling the mutation. Errors are turned into UI toasts. */
35 run(...args: Args): void;
......@@ -12,6 +14,8 @@ export interface Mutation<Args extends unknown[], Result> {
1214 cb: (update: MutationEvent<Result>) => void,
1315 ): () => void;
1416 describe(...args: Args): string;
17 describeResult?: (args: Args, result: Result) => string | undefined;
18 client: MutationClient<object, object>;
1519}
1620
1721export interface MutationEvent<Result> {
test/batch.test.ts+3-3
......@@ -31,7 +31,7 @@ function createTestClient() {
3131 },
3232 };
3333 },
34 reportError(error) {
34 reportError(message, error) {
3535 errors.push(error);
3636 },
3737 });
......@@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => {
472472 },
473473 };
474474 },
475 reportError(error) {
475 reportError(message, error) {
476476 errors.push(error);
477477 },
478478 });
......@@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => {
518518 },
519519 };
520520 },
521 reportError(error) {
521 reportError(message, error) {
522522 errors.push(error);
523523 },
524524 deepEquals(a, b) {
test/object-path-types.test.ts+6-10
......@@ -3,16 +3,13 @@
33 * These tests verify that TypeScript types work correctly at compile time
44 */
55
6import type {
7 AllObjectPaths,
8 GetObjectPath,
9} from "../src/object-path.ts";
6import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";
107
118// Type testing utilities
129type Expect<T extends true> = T;
13type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
14 ? 1
15 : 2 ? true
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
11 <T>() => T extends Y ? 1
12 : 2 ? true
1613 : false;
1714type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
1815type IsAny<T> = 0 extends 1 & T ? true : false;
......@@ -348,7 +345,7 @@ type ArrayRemoveSignature<
348345 Data extends object,
349346 Path extends AllObjectPaths<Data>,
350347> = 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
352349 : never;
353350
354351declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;
......@@ -379,8 +376,7 @@ type IncrementNameTest = Expect<
379376type ToggleSignature<
380377 Data extends object,
381378 Path extends AllObjectPaths<Data>,
382> = GetObjectPath<Data, Path> extends boolean
383 ? (path: Path) => void
379> = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void
384380 : never;
385381
386382declare const toggle: ToggleSignature<TestData, ["active"]>;
test/queued.test.ts+1-1
......@@ -16,7 +16,7 @@ function createTestClient() {
1616 },
1717 };
1818 },
19 reportError(error) {
19 reportError(message, error) {
2020 errors.push(error);
2121 },
2222 });
test/tanstack-query-helpers.test.ts+12-6
......@@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => {
586586 helpers.objArrayUpdate(
587587 queryTest,
588588 ["items"],
589 (item) => item.id === 2,
590 (item) => ({ ...item, label: "UPDATED" }),
589 {
590 filter: (item) => item.id === 2,
591 update: (item) => ({ ...item, label: "UPDATED" }),
592 },
591593 );
592594
593595 const result = client.getQueryData<TestData>(queryTest.queryKey);
......@@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => {
611613 helpers.objArrayUpdate(
612614 queryTest,
613615 ["items"],
614 (item) => item.id > 1,
615 (item) => ({ ...item, label: item.label.toUpperCase() }),
616 {
617 filter: (item) => item.id > 1,
618 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
619 },
616620 );
617621
618622 const result = client.getQueryData<TestData>(queryTest.queryKey);
......@@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => {
632636 helpers.objArrayUpdate(
633637 queryTest,
634638 ["items"],
635 (_item, index) => index === 0,
636 (item) => ({ ...item, label: "FIRST" }),
639 {
640 filter: (_item, index) => index === 0,
641 update: (item) => ({ ...item, label: "FIRST" }),
642 },
637643 );
638644
639645 const result = client.getQueryData<TestData>(queryTest.queryKey);