authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 20:36:36-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 22:05:20-08:00
log005380d2610353262fa5acd170352dbbf3e4e6c2
tree2f66ae1a07141eefd534d1e8885916242bf2f66d
parent4591d794aff944fbb510d589770021c9ccb60806
signaturelock-open Commit is signed but in an unrecognized format.

autofmt


19 files changed, 113 insertions(+), 203 deletions(-)

dprint.jsonc created+13
...@@ -0,0 +1,13 @@
1{
2 "excludes": [
3 "**/node_modules",
4 "**/*-lock.json",
5 ],
6 "plugins": [
7 "https://plugins.dprint.dev/typescript-0.95.13.wasm",
8 "https://plugins.dprint.dev/json-0.21.1.wasm",
9 "https://plugins.dprint.dev/markdown-0.20.0.wasm",
10 "https://plugins.dprint.dev/g-plane/malva-v0.15.2.wasm",
11 "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
12 ],
13}
example/src/App.tsx+1-6
...@@ -1,10 +1,5 @@...@@ -1,10 +1,5 @@
1import { createMutationButton, MutationClient, queryClientOptimisticHelpers, useMutate } from "@clo/react-mutation";
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import {
3 createMutationButton,
4 MutationClient,
5 queryClientOptimisticHelpers,
6 useMutate,
7} from "@clo/react-mutation";
8import { queryOptions as queryOptions } from "@tanstack/react-query";3import { queryOptions as queryOptions } from "@tanstack/react-query";
9import { useSuspenseQuery } from "@tanstack/react-query";4import { useSuspenseQuery } from "@tanstack/react-query";
10import { QueryKeyAndFn } from "../../src/tanstack-query.ts";5import { QueryKeyAndFn } from "../../src/tanstack-query.ts";
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.10",3 "version": "1.0.0-beta.11",
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",
readme.md+28-63
...@@ -10,7 +10,7 @@ their mutation story falls apart, is confusing, and misses a few obvious...@@ -10,7 +10,7 @@ their mutation story falls apart, is confusing, and misses a few obvious
10features. Additionally, coworkers using AI agents continue to propagate bad10features. Additionally, coworkers using AI agents continue to propagate bad
11patterns and verbose code that is hard to review.11patterns and verbose code that is hard to review.
1212
13The primary gains React Mutation provides are13The primary gains React Mutation provides are:
1414
15- **Automatic result handling**. If a `useMutate` hook does not observe15- **Automatic result handling**. If a `useMutate` hook does not observe
16 `isError`, unhandled errors will be propagated to a global handler, which can16 `isError`, unhandled errors will be propagated to a global handler, which can
...@@ -19,17 +19,20 @@ The primary gains React Mutation provides are...@@ -19,17 +19,20 @@ The primary gains React Mutation provides are
19 UI without worrying about bugged error states. The19 UI without worrying about bugged error states. The
20 [built in helpers for React Query](#react-query-optimistic-helpers) shows20 [built in helpers for React Query](#react-query-optimistic-helpers) shows
21 this power in more detail.21 this power in more detail.
22- Easy debouncing and batching utilities.22- Extra treats such as debouncing (toggle button spam) and no-op filters (auto-save text inputs).
2323
24## Setup24## Setup
2525
26React Mutation starts with a `MutationClient`, which shares global state for an application.26React Mutation starts with a `MutationClient`, which shares global state for an application.
2727
28```ts28```ts
29import { QueryClient } from "@tanstack/react-query";
30import { MutationClient } from "@clo/react-mutation";
31import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation";
32import { showToastUI } from "...";29import { showToastUI } from "...";
30import { MutationClient } from "@clo/react-mutation";
31import {
32 boundQueryClientGet,
33 queryClientOptimisticHelpers,
34} from "@clo/react-mutation";
35import { QueryClient } from "@tanstack/react-query";
3336
34const queryClient = new QueryClient();37const queryClient = new QueryClient();
35export const mutations = new MutationClient({38export const mutations = new MutationClient({
...@@ -40,12 +43,12 @@ export const mutations = new MutationClient({...@@ -40,12 +43,12 @@ export const mutations = new MutationClient({
40 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`)43 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`)
41 get: (k: QueryKey) => client.getQueryData(k),44 get: (k: QueryKey) => client.getQueryData(k),
42 },45 },
43 46
44 // Optimistic helpers are a second type of context, only available within47 // Optimistic helpers are a second type of context, only available within
45 // optimistic update functions. These functions are bound to each mutation,48 // optimistic update functions. These functions are bound to each mutation,
46 // which means they can handle automatic rollbacks and query invalidation.49 // which means they can handle automatic rollbacks and query invalidation.
47 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),50 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
48 51
49 // When call sites do not opt into handling errors, or a pending52 // When call sites do not opt into handling errors, or a pending
50 // mutation hook is unmounted, errors are sent to this function.53 // mutation hook is unmounted, errors are sent to this function.
51 // An example is to bind this to global a UI toast.54 // An example is to bind this to global a UI toast.
...@@ -53,7 +56,7 @@ export const mutations = new MutationClient({...@@ -53,7 +56,7 @@ export const mutations = new MutationClient({
53 showToastUI("error", userFriendlyErrorMessage);56 showToastUI("error", userFriendlyErrorMessage);
54 console.error(error); // or send to telemetry57 console.error(error); // or send to telemetry
55 },58 },
56 59
57 // Similarly, when call sites do opt into handling success.60 // Similarly, when call sites do opt into handling success.
58 reportSuccess(userFriendlySuccessMessage: string) {61 reportSuccess(userFriendlySuccessMessage: string) {
59 showToastUI("success", userFriendlyErrorMessage);62 showToastUI("success", userFriendlyErrorMessage);
...@@ -234,19 +237,18 @@ anything, `snapshot` can be used to detect no-op mutations....@@ -234,19 +237,18 @@ anything, `snapshot` can be used to detect no-op mutations.
234237
235```tsx238```tsx
236const mutUpdateField = mutations.define({239const mutUpdateField = mutations.define({
237 async mutate(id: string, value: string) { /* mutation */ },240 async mutate(id: string, value: string) {/* mutation */},
238 241
239 optimistic({ args: [id, value], helpers }) {242 optimistic({ args: [id, value], helpers }) {
240 helpers.objSet(queryItem(id), ["value"], value);243 helpers.objSet(queryItem(id), ["value"], value);
241 },244 },
242 245
243 // called once before `optimistic` and once after. if the values are equal,246 // called once before `optimistic` and once after. if the values are equal,
244 // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`)247 // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`)
245 // (defaulting to a json-based deep equal check, customize in MutationClient)248 // (defaulting to a json-based deep equal check, customize in MutationClient)
246 snapshot({ args: [id], get }) {249 snapshot({ args: [id], get }) {
247 return get(queryItem(id))?.value;250 return get(queryItem(id))?.value;
248 }251 },
249
250 // (...describe and optionally debounce stuff...)252 // (...describe and optionally debounce stuff...)
251});253});
252```254```
...@@ -328,56 +330,19 @@ It can now be used for easy mutations:...@@ -328,56 +330,19 @@ It can now be used for easy mutations:
328```tsx330```tsx
329<>331<>
330 {/* Static Arguments */}332 {/* Static Arguments */}
331 <MutationButton mutation={mutToggleFollow} args={[ userId ]}>Follow</MutationButton>333 <MutationButton mutation={mutToggleFollow} args={[userId]}>
334 Follow
335 </MutationButton>
332336
333 {/* Dynamic Arguments */}337 {/* Dynamic Arguments */}
334 <MutationButton mutation={mutSendMessage} args={(e) => {338 <MutationButton
335 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit339 mutation={mutSendMessage}
336 return [userId, messageContent];340 args={(e) => {
337 }}>Send Message</MutationButton>341 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
338</>342 return [userId, messageContent];
339```343 }}
340344 >
341345 Send Message
342346 </MutationButton>
343## Batched Mutations347</>;
344
345This is an advanced feature. Complete Documentation is pending. It is not recommended to use this.
346
347Each call to the mutation applies new optimistic state on top of the previous,
348and after a debounce / throttle, the new optimistic state is committed to the
349API. UI never shows a pending state for these. This works great for toggle buttons
350and any other state where you'd like to define an optimistic state
351
352In many places, similar behavior can be achieved with standard mutations and its
353`debounceMs` field.
354
355```tsx
356const mutToggleFollow = mutations.defineBatched({
357 // Think of your mutator in terms of how it applies optimistic state.
358 optimistic({ helpers }, userId: string) {
359 helpers.objToggle(queryUser(userId), ["following"]);
360 },
361 // A value is snapshotted *before* calling `optimistic`, and then again after
362 // the timer. If the snapshots differ, then `commit` function is called.
363 getValue: ({ get }) => get(queryUser(id))?.following,
364
365 // Split different `id`s into their own batches.
366 key: ({ args: [id] }) => id,
367
368 // Commit the result to the backend.
369 // Here, you can observe the two snapshotted values and form an API request.
370 async commit({ initial, current, args: [id] }) {
371 const response = await fetch(`/items/${id}`, {
372 method: "patch",
373 body: JSON.stringify({ title: current }),
374 });
375 if (!response.ok) throw new Error(`HTTP ${response.status}`);
376 },
377
378 describe: ({ get, args: [id] }) =>
379 `Rename '${get(queryItem())?.title ?? 'Unknown Item'}'`,
380 describeResult: ({ get, args: [id] }) =>
381 `Renamed '${get(queryItem(id))?.title ?? 'Unknown Item'}'`,
382});
383```348```
src/blocking.ts+13-24
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";3import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
55
6/**6/**
7 * Argument to `defineBlocking`.7 * Argument to `defineBlocking`.
...@@ -138,8 +138,8 @@ export class BlockingMutation<...@@ -138,8 +138,8 @@ export class BlockingMutation<
138 }138 }
139139
140 key(args: Args) {140 key(args: Args) {
141 const k = this.#options.key?.({ ...this.#client.context, args }) ??141 const k = this.#options.key?.({ ...this.#client.context, args })
142 "shared";142 ?? "shared";
143 return JSON.stringify(k);143 return JSON.stringify(k);
144 }144 }
145145
...@@ -263,9 +263,7 @@ export class BlockingMutation<...@@ -263,9 +263,7 @@ export class BlockingMutation<
263263
264 // Call global handler unless suppressed264 // Call global handler unless suppressed
265 if (!suppressGlobalError) {265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
267 errMessage(error)
268 }`;
269 this.#client.reportError(message, error);267 this.#client.reportError(message, error);
270 }268 }
271 });269 });
...@@ -354,8 +352,7 @@ export class BlockingMutation<...@@ -354,8 +352,7 @@ export class BlockingMutation<
354 expired = true;352 expired = true;
355 let next;353 let next;
356 while (354 while (
357 next =355 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
358 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
359 ) {356 ) {
360 next();357 next();
361 }358 }
...@@ -408,9 +405,7 @@ export class BlockingMutation<...@@ -408,9 +405,7 @@ export class BlockingMutation<
408 // Report any errors from refetch or callbacks405 // Report any errors from refetch or callbacks
409 results.forEach((result) => {406 results.forEach((result) => {
410 if (result.status === "rejected") {407 if (result.status === "rejected") {
411 const message = `Failed to refetch after ${408 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
412 this.describe(...args)
413 }: ${errMessage(result.reason)}`;
414 this.#client.reportError(message, result.reason);409 this.#client.reportError(message, result.reason);
415 }410 }
416 });411 });
...@@ -447,9 +442,7 @@ export class BlockingMutation<...@@ -447,9 +442,7 @@ export class BlockingMutation<
447 // Report any errors from refetch or callbacks442 // Report any errors from refetch or callbacks
448 results.forEach((result) => {443 results.forEach((result) => {
449 if (result.status === "rejected") {444 if (result.status === "rejected") {
450 const message = `Failed to refetch after ${445 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
451 this.describe(...args)
452 }: ${errMessage(result.reason)}`;
453 this.#client.reportError(message, result.reason);446 this.#client.reportError(message, result.reason);
454 }447 }
455 });448 });
...@@ -533,8 +526,7 @@ export class BlockingMutation<...@@ -533,8 +526,7 @@ export class BlockingMutation<
533 // Roll back the rollbacks we just added526 // Roll back the rollbacks we just added
534 let next;527 let next;
535 while (528 while (
536 next =529 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
537 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
538 ) {530 ) {
539 next();531 next();
540 }532 }
...@@ -611,13 +603,12 @@ export class BlockingMutation<...@@ -611,13 +603,12 @@ export class BlockingMutation<
611 return;603 return;
612 }604 }
613605
614 const { args, rollbackCount, pending, onSuccess } =606 const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced;
615 channel.pendingDebounced;
616 channel.pendingDebounced = null;607 channel.pendingDebounced = null;
617608
618 // Check if there are any listeners at time of enqueue609 // Check if there are any listeners at time of enqueue
619 const hasListeners = channel.listeners.size > 0;610 const hasListeners = channel.listeners.size > 0;
620 611
621 // Create wrapper resolve/reject that resolves ALL pending promises612 // Create wrapper resolve/reject that resolves ALL pending promises
622 const {613 const {
623 promise: wrapperPromise,614 promise: wrapperPromise,
...@@ -630,7 +621,7 @@ export class BlockingMutation<...@@ -630,7 +621,7 @@ export class BlockingMutation<
630 (result) => {621 (result) => {
631 // Resolve all pending promises622 // Resolve all pending promises
632 pending.forEach((p) => p.resolve(result));623 pending.forEach((p) => p.resolve(result));
633 624
634 // Check if there are any listeners at execution time625 // Check if there are any listeners at execution time
635 const hasListeners = channel.listeners.size > 0;626 const hasListeners = channel.listeners.size > 0;
636 if (!hasListeners) {627 if (!hasListeners) {
...@@ -643,13 +634,11 @@ export class BlockingMutation<...@@ -643,13 +634,11 @@ export class BlockingMutation<
643 (error) => {634 (error) => {
644 // Reject all pending promises635 // Reject all pending promises
645 pending.forEach((p) => p.reject(error));636 pending.forEach((p) => p.reject(error));
646 637
647 // Check if there are any listeners at execution time638 // Check if there are any listeners at execution time
648 const hasListeners = channel.listeners.size > 0;639 const hasListeners = channel.listeners.size > 0;
649 if (!hasListeners) {640 if (!hasListeners) {
650 const message = `Failed to ${this.describe(...args)}: ${641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
651 errMessage(error)
652 }`;
653 this.#client.reportError(message, error);642 this.#client.reportError(message, error);
654 }643 }
655 },644 },
src/client.ts+6-8
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1import {
2 DebouncedMutation,
3 type DebouncedMutationOptions,
4} from "./debounced.ts";
5import { BlockingMutation, type MutationOptions } from "./blocking.ts";1import { BlockingMutation, type MutationOptions } from "./blocking.ts";
2import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
6import type { Mutation } from "./types.ts";3import type { Mutation } from "./types.ts";
74
8export interface MutationClientConfig {5export interface MutationClientConfig {
...@@ -10,11 +7,12 @@ export interface MutationClientConfig {...@@ -10,11 +7,12 @@ export interface MutationClientConfig {
10 optimisticHelpers: {};7 optimisticHelpers: {};
11}8}
129
13export type MutationClientFromConfig<Config extends MutationClientConfig> =10export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient<
14 MutationClient<Config["context"], Config["optimisticHelpers"]>;11 Config["context"],
12 Config["optimisticHelpers"]
13>;
1514
16const defaultDeepEquals = (a: unknown, b: unknown): boolean =>15const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
17 JSON.stringify(a) === JSON.stringify(b);
1816
19export interface MutationClientOptions<17export interface MutationClientOptions<
20 Context extends object,18 Context extends object,
src/debounced.ts+6-16
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";3import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
55
6export interface DebouncedMutationOptions<6export interface DebouncedMutationOptions<
7 Args extends unknown[],7 Args extends unknown[],
...@@ -288,9 +288,7 @@ export class DebouncedMutation<...@@ -288,9 +288,7 @@ export class DebouncedMutation<
288 );288 );
289 }289 }
290 this.#runAndReturn(args, true, undefined).catch((error) => {290 this.#runAndReturn(args, true, undefined).catch((error) => {
291 const message = `Failed to ${this.describe(...args)}: ${291 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
292 errMessage(error)
293 }`;
294 this.#client.reportError(message, error);292 this.#client.reportError(message, error);
295 });293 });
296 }294 }
...@@ -321,9 +319,7 @@ export class DebouncedMutation<...@@ -321,9 +319,7 @@ export class DebouncedMutation<
321319
322 // Call global error handler unless suppressed320 // Call global error handler unless suppressed
323 if (!suppressGlobalError) {321 if (!suppressGlobalError) {
324 const message = `Failed to ${this.describe(...args)}: ${322 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
325 errMessage(error)
326 }`;
327 this.#client.reportError(message, error);323 this.#client.reportError(message, error);
328 }324 }
329 });325 });
...@@ -525,9 +521,7 @@ export class DebouncedMutation<...@@ -525,9 +521,7 @@ export class DebouncedMutation<
525 pendingItems.forEach(({ resolve }) => resolve(result));521 pendingItems.forEach(({ resolve }) => resolve(result));
526522
527 // Report success globally if any of the pending items requested it523 // Report success globally if any of the pending items requested it
528 const shouldReportSuccess = pendingItems.some((item) =>524 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
529 item.reportSuccessGlobally
530 );
531 if (shouldReportSuccess) {525 if (shouldReportSuccess) {
532 const message = this.#describeResult(526 const message = this.#describeResult(
533 firstArgs,527 firstArgs,
...@@ -557,9 +551,7 @@ export class DebouncedMutation<...@@ -557,9 +551,7 @@ export class DebouncedMutation<
557 // Report any errors from refetch or callbacks551 // Report any errors from refetch or callbacks
558 results.forEach((result) => {552 results.forEach((result) => {
559 if (result.status === "rejected") {553 if (result.status === "rejected") {
560 const message = `Failed to refetch after ${554 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
561 this.describe(...firstArgs)
562 }: ${errMessage(result.reason)}`;
563 this.#client.reportError(message, result.reason);555 this.#client.reportError(message, result.reason);
564 }556 }
565 });557 });
...@@ -596,9 +588,7 @@ export class DebouncedMutation<...@@ -596,9 +588,7 @@ export class DebouncedMutation<
596 // Report any errors from refetch or callbacks588 // Report any errors from refetch or callbacks
597 results.forEach((result) => {589 results.forEach((result) => {
598 if (result.status === "rejected") {590 if (result.status === "rejected") {
599 const message = `Failed to refetch after ${591 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
600 this.describe(...firstArgs)
601 }: ${errMessage(result.reason)}`;
602 this.#client.reportError(message, result.reason);592 this.#client.reportError(message, result.reason);
603 }593 }
604 });594 });
src/mod.ts+2-6
...@@ -1,16 +1,11 @@...@@ -1,16 +1,11 @@
1export type { MutationOptions, OptimisticContext } from "./blocking.ts";1export type { MutationOptions, OptimisticContext } from "./blocking.ts";
2export type {
3 DebouncedCommitContext,
4 DebouncedMutationOptions,
5 DebouncedOptimisticContext,
6} from "./debounced.ts";
7export {2export {
8 MutationClient,3 MutationClient,
9 type MutationClientConfig,4 type MutationClientConfig,
10 type MutationClientFromConfig,5 type MutationClientFromConfig,
11 type MutationClientOptions,6 type MutationClientOptions,
12} from "./client.ts";7} from "./client.ts";
13export type { Mutation, MutationEvent } from "./types.ts";8export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts";
14export {9export {
15 createMutationButton,10 createMutationButton,
16 type MutationButtonComponent,11 type MutationButtonComponent,
...@@ -22,3 +17,4 @@ export {...@@ -22,3 +17,4 @@ export {
22 type UseMutateResultBase,17 type UseMutateResultBase,
23 type UseMutateSuccess,18 type UseMutateSuccess,
24} from "./react.ts";19} from "./react.ts";
20export type { Mutation, MutationEvent } from "./types.ts";
src/object-path.ts+3-4
...@@ -1,13 +1,12 @@...@@ -1,13 +1,12 @@
1export type AllObjectPaths<T, Filter = unknown> = T extends1export type AllObjectPaths<T, Filter = unknown> = T extends ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
2 ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
3 : T extends object ?2 : T extends object ?
4 | {3 | {
5 [K in keyof T]-?: [K, ...AllObjectPaths<T[K]>];4 [K in keyof T]-?: [K, ...AllObjectPaths<T[K]>];
6 }[keyof T]5 }[keyof T]
7 | []6 | []
8 : [];7 : [];
9export type GetObjectPath<T, P extends unknown[]> = P extends8export type GetObjectPath<T, P extends unknown[]> = P extends [infer K extends keyof T, ...infer Rest]
10 [infer K extends keyof T, ...infer Rest] ? GetObjectPath<T[K], Rest>9 ? GetObjectPath<T[K], Rest>
11 : T;10 : T;
1211
13/** Get an object path property */12/** Get an object path property */
src/react.ts+13-19
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { message as errMessage } from "@clo/lib/error.ts";
1import {2import {
2 type FC,3 type FC,
3 type MouseEvent,4 type MouseEvent,
...@@ -7,9 +8,8 @@ import {...@@ -7,9 +8,8 @@ import {
7 useEffect,8 useEffect,
8 useState,9 useState,
9} from "react";10} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation, RunOptions } from "./types.ts";
12import { jsx } from "react/jsx-runtime";11import { jsx } from "react/jsx-runtime";
12import type { Mutation, RunOptions } from "./types.ts";
1313
14/**14/**
15 * Subscribe to a mutation's status, as well as accessing a local `run` method.15 * Subscribe to a mutation's status, as well as accessing a local `run` method.
...@@ -170,9 +170,7 @@ class Observer<Args extends unknown[], Result> {...@@ -170,9 +170,7 @@ class Observer<Args extends unknown[], Result> {
170 if (!error) return undefined;170 if (!error) return undefined;
171 const mutation = this.mutation;171 const mutation = this.mutation;
172 if (!mutation || !this.state.args) return errMessage(error);172 if (!mutation || !this.state.args) return errMessage(error);
173 return `Failed to ${mutation.describe(...this.state.args)}: ${173 return `Failed to ${mutation.describe(...this.state.args)}: ${errMessage(error)}`;
174 errMessage(error)
175 }`;
176 }174 }
177175
178 run(...args: Args) {176 run(...args: Args) {
...@@ -212,8 +210,8 @@ class Observer<Args extends unknown[], Result> {...@@ -212,8 +210,8 @@ class Observer<Args extends unknown[], Result> {
212 isPending: status === "mutating" || status === "refetching",210 isPending: status === "mutating" || status === "refetching",
213 isSuccess: hasResult && !hasError,211 isSuccess: hasResult && !hasError,
214 isError: hasError,212 isError: hasError,
215 isOptimisticData: status === "waiting" || status === "mutating" ||213 isOptimisticData: status === "waiting" || status === "mutating"
216 status === "refetching",214 || status === "refetching",
217 args: hasError || hasResult ? undefined : this.state.args,215 args: hasError || hasResult ? undefined : this.state.args,
218 });216 });
219 },217 },
...@@ -222,10 +220,10 @@ class Observer<Args extends unknown[], Result> {...@@ -222,10 +220,10 @@ class Observer<Args extends unknown[], Result> {
222 // Use global error/success handling if this usage of the hook doesn't check for220 // Use global error/success handling if this usage of the hook doesn't check for
223 // errors or success. This makes it act pretty awesome in terms of defaults.221 // errors or success. This makes it act pretty awesome in terms of defaults.
224 // You don't have to worry about result UI, they'll surface exactly once.222 // You don't have to worry about result UI, they'll surface exactly once.
225 const watchesError = this.watched.has("isError") ||223 const watchesError = this.watched.has("isError")
226 this.watched.has("error") || this.watched.has("errorMessage");224 || this.watched.has("error") || this.watched.has("errorMessage");
227 const watchesSuccess = this.watched.has("isSuccess") ||225 const watchesSuccess = this.watched.has("isSuccess")
228 this.watched.has("result");226 || this.watched.has("result");
229 const promise = mutation.runAsPromise(...args)227 const promise = mutation.runAsPromise(...args)
230 .then((result) => {228 .then((result) => {
231 if (!watchesSuccess && mutation.describeResult) {229 if (!watchesSuccess && mutation.describeResult) {
...@@ -237,9 +235,7 @@ class Observer<Args extends unknown[], Result> {...@@ -237,9 +235,7 @@ class Observer<Args extends unknown[], Result> {
237 });235 });
238 promise.catch((err) => {236 promise.catch((err) => {
239 if (!watchesError) {237 if (!watchesError) {
240 const message = `Failed to ${mutation.describe(...args)}: ${238 const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`;
241 errMessage(err)
242 }`;
243 mutation.client.reportError(message, err);239 mutation.client.reportError(message, err);
244 }240 }
245 });241 });
...@@ -289,8 +285,8 @@ class Observer<Args extends unknown[], Result> {...@@ -289,8 +285,8 @@ class Observer<Args extends unknown[], Result> {
289 isPending: status === "mutating" || status === "refetching",285 isPending: status === "mutating" || status === "refetching",
290 isSuccess: hasResult && !hasError,286 isSuccess: hasResult && !hasError,
291 isError: hasError,287 isError: hasError,
292 isOptimisticData: status === "waiting" || status === "mutating" ||288 isOptimisticData: status === "waiting" || status === "mutating"
293 status === "refetching",289 || status === "refetching",
294 args: hasError || hasResult ? undefined : this.state.args,290 args: hasError || hasResult ? undefined : this.state.args,
295 });291 });
296 },292 },
...@@ -429,9 +425,7 @@ export function createMutationButton<Props>(...@@ -429,9 +425,7 @@ export function createMutationButton<Props>(
429 // back into unspecified generics.425 // back into unspecified generics.
430 .bind(null, Component) as MutationButtonComponent<BareProps>;426 .bind(null, Component) as MutationButtonComponent<BareProps>;
431 // react devtools loves display names427 // react devtools loves display names
432 bound.displayName = `MutationButton[${428 bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`;
433 Component.displayName ?? Component.name
434 }]`;
435429
436 return bound;430 return bound;
437}431}
src/tanstack-query.ts+5-21
...@@ -1,16 +1,6 @@...@@ -1,16 +1,6 @@
1import {1import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query";
2 QueryClient,
3 type QueryFunction,
4 type QueryKey,
5 type Updater,
6} from "@tanstack/react-query";
7import {
8 type AllObjectPaths,
9 type GetObjectPath,
10 getPath,
11 setPath,
12} from "./object-path.ts";
13import type { OptimisticEvents } from "./client.ts";2import type { OptimisticEvents } from "./client.ts";
3import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts";
144
15export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {5export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
16 queryKey: Key;6 queryKey: Key;
...@@ -336,9 +326,7 @@ class TanstackQueryOptimisticHelpers {...@@ -336,9 +326,7 @@ class TanstackQueryOptimisticHelpers {
336 const { value: original, exists } = getPath(prev, path);326 const { value: original, exists } = getPath(prev, path);
337 if (!exists || !Array.isArray(original)) return;327 if (!exists || !Array.isArray(original)) return;
338328
339 const newArray = original.filter((item, index) =>329 const newArray = original.filter((item, index) => !removeFilter(item, index));
340 !removeFilter(item, index)
341 );
342 this.#set(330 this.#set(
343 queryKey,331 queryKey,
344 (obj) => obj ? setPath(obj, path, newArray as any) : obj,332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
...@@ -405,9 +393,7 @@ class TanstackQueryOptimisticHelpers {...@@ -405,9 +393,7 @@ class TanstackQueryOptimisticHelpers {
405 const { value: original, exists } = getPath(prev, path);393 const { value: original, exists } = getPath(prev, path);
406 if (!exists || !Array.isArray(original)) return;394 if (!exists || !Array.isArray(original)) return;
407395
408 const newArray = original.map((item, index) =>396 const newArray = original.map((item, index) => filter(item, index) ? update(item) : item);
409 filter(item, index) ? update(item) : item
410 );
411 this.#set(397 this.#set(
412 queryKey,398 queryKey,
413 (obj) => obj ? setPath(obj, path, newArray as any) : obj,399 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
...@@ -549,9 +535,7 @@ class TanstackQueryOptimisticHelpers {...@@ -549,9 +535,7 @@ class TanstackQueryOptimisticHelpers {
549 const prev = this.#get(queryKey);535 const prev = this.#get(queryKey);
550 if (!prev || !Array.isArray(prev)) return;536 if (!prev || !Array.isArray(prev)) return;
551537
552 const newArray = prev.map((item, index) =>538 const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item);
553 (filter ? filter(item, index) : true) ? update(item) : item
554 );
555 this.#set(queryKey, newArray);539 this.#set(queryKey, newArray);
556 this.#onRestore(() => {540 this.#onRestore(() => {
557 // TODO: splice items back in case original changed541 // TODO: splice items back in case original changed
test/blocking-debounce-edge-cases.test.ts+2-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { assertEquals, assertRejects } from "@std/assert";1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
2import { MutationClient } from "../src/client.ts";3import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";4import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
6// Helper to create a test mutation client6// Helper to create a test mutation client
7function createTestClient() {7function createTestClient() {
...@@ -116,7 +116,7 @@ test("BlockingMutation - debounce: mutation still executes after all listeners u...@@ -116,7 +116,7 @@ test("BlockingMutation - debounce: mutation still executes after all listeners u
116116
117 // Mutation should have been called despite no listeners117 // Mutation should have been called despite no listeners
118 assertEquals(mutateCallCount, 1);118 assertEquals(mutateCallCount, 1);
119 119
120 // Global success handler should be called since no local listeners120 // Global success handler should be called since no local listeners
121 assertEquals(successes.length, 1);121 assertEquals(successes.length, 1);
122 assertEquals(successes[0], "Successfully processed test");122 assertEquals(successes[0], "Successfully processed test");
test/blocking.test.ts+3-7
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { assertEquals, assertRejects } from "@std/assert";1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
2import { MutationClient } from "../src/client.ts";3import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";4import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
6// Helper to create a test mutation client6// Helper to create a test mutation client
7function createTestClient() {7function createTestClient() {
...@@ -731,9 +731,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {...@@ -731,9 +731,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
731 await assertRejects(() => mutation.runAsPromise("test"));731 await assertRejects(() => mutation.runAsPromise("test"));
732732
733 // Should have error event733 // Should have error event
734 const errorEvents = tracker.events.filter((e) =>734 const errorEvents = tracker.events.filter((e) => e.status === "mutating" && e.error);
735 e.status === "mutating" && e.error
736 );
737 assertEquals(errorEvents.length > 0, true);735 assertEquals(errorEvents.length > 0, true);
738 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");736 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
739});737});
...@@ -819,9 +817,7 @@ test("BlockingMutation - result is passed to notification on success", async ()...@@ -819,9 +817,7 @@ test("BlockingMutation - result is passed to notification on success", async ()
819 await delay(20);817 await delay(20);
820818
821 // Should have refetching event with result819 // Should have refetching event with result
822 const refetchingEvents = tracker.events.filter((e) =>820 const refetchingEvents = tracker.events.filter((e) => e.status === "refetching");
823 e.status === "refetching"
824 );
825 assertEquals(refetchingEvents.length > 0, true);821 assertEquals(refetchingEvents.length > 0, true);
826 assertEquals(refetchingEvents[0]?.result, "result-test");822 assertEquals(refetchingEvents[0]?.result, "result-test");
827});823});
test/debounced.test.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { assertEquals, assertRejects } from "@std/assert";1import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
2import { MutationClient } from "../src/client.ts";3import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";4import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
6// Shared test store for optimistic updates6// Shared test store for optimistic updates
7const testStore = new Map<string, number>();7const testStore = new Map<string, number>();
test/object-path.test.ts+2-2
...@@ -282,11 +282,11 @@ test("set - should share references for unchanged branches (structural sharing)"...@@ -282,11 +282,11 @@ test("set - should share references for unchanged branches (structural sharing)"
282 };282 };
283283
284 const result = setPath(obj, ["address", "city"], "LA");284 const result = setPath(obj, ["address", "city"], "LA");
285 285
286 // Changed path should have new references286 // Changed path should have new references
287 assertEquals(result === obj, false); // Root is new287 assertEquals(result === obj, false); // Root is new
288 assertEquals(result.address === obj.address, false); // Address is new288 assertEquals(result.address === obj.address, false); // Address is new
289 289
290 // Unchanged branches should share references290 // Unchanged branches should share references
291 assertEquals(result.hobbies === obj.hobbies, true); // Same reference291 assertEquals(result.hobbies === obj.hobbies, true); // Same reference
292 assertEquals(result.nested === obj.nested, true); // Same reference292 assertEquals(result.nested === obj.nested, true); // Same reference
test/object-path.types.ts+5-14
...@@ -7,9 +7,8 @@ import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";...@@ -7,9 +7,8 @@ import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";
77
8// Type testing utilities8// Type testing utilities
9type Expect<T extends true> = T;9type Expect<T extends true> = T;
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1
11 <T>() => T extends Y ? 111 : 2 ? true
12 : 2 ? true
13 : false;12 : false;
14type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;13type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
15type IsAny<T> = 0 extends 1 & T ? true : false;14type IsAny<T> = 0 extends 1 & T ? true : false;
...@@ -325,8 +324,7 @@ objSetCount(["count"], (n) => n + 1);...@@ -325,8 +324,7 @@ objSetCount(["count"], (n) => n + 1);
325type ArrayPushSignature<324type ArrayPushSignature<
326 Data extends object,325 Data extends object,
327 Path extends AllObjectPaths<Data>,326 Path extends AllObjectPaths<Data>,
328> = GetObjectPath<Data, Path> extends readonly (infer T)[]327> = GetObjectPath<Data, Path> extends readonly (infer T)[] ? (path: Path, ...items: T[]) => void
329 ? (path: Path, ...items: T[]) => void
330 : never;328 : never;
331329
332// This should accept individual items, not arrays330// This should accept individual items, not arrays
...@@ -359,8 +357,7 @@ arrayRemoveTags(["tags"], (tag) => tag === "alpha");...@@ -359,8 +357,7 @@ arrayRemoveTags(["tags"], (tag) => tag === "alpha");
359type IncrementSignature<357type IncrementSignature<
360 Data extends object,358 Data extends object,
361 Path extends AllObjectPaths<Data>,359 Path extends AllObjectPaths<Data>,
362> = GetObjectPath<Data, Path> extends number360> = GetObjectPath<Data, Path> extends number ? (path: Path, amount?: number) => void
363 ? (path: Path, amount?: number) => void
364 : never;361 : never;
365362
366declare const increment: IncrementSignature<TestData, ["count"]>;363declare const increment: IncrementSignature<TestData, ["count"]>;
...@@ -398,10 +395,4 @@ type NoAnyTest4 = Expect<...@@ -398,10 +395,4 @@ type NoAnyTest4 = Expect<
398 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>395 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>
399>;396>;
400397
401export type {398export type { ArrayPushSignature, ArrayRemoveSignature, IncrementSignature, ObjSetSignature, ToggleSignature };
402 ArrayPushSignature,
403 ArrayRemoveSignature,
404 IncrementSignature,
405 ObjSetSignature,
406 ToggleSignature,
407};
test/react-button.test.tsx+4-4
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { render, screen, waitFor } from "@testing-library/react";1import { render, screen, waitFor } from "@testing-library/react";
2import { userEvent } from "@testing-library/user-event";2import { userEvent } from "@testing-library/user-event";
3import { describe, test, expect, vi } from "vitest";
4import { MutationClient } from "../src/client.ts";
5import { useMutate, createMutationButton } from "../src/react.ts";
6import type { FC } from "react";3import type { FC } from "react";
4import { describe, expect, test, vi } from "vitest";
5import { MutationClient } from "../src/client.ts";
6import { createMutationButton, useMutate } from "../src/react.ts";
77
8// Helper to create a test mutation client8// Helper to create a test mutation client
9function createTestClient() {9function createTestClient() {
...@@ -632,7 +632,7 @@ describe("createMutationButton - Edge Cases", () => {...@@ -632,7 +632,7 @@ describe("createMutationButton - Edge Cases", () => {
632 expect(completedCount).toBeGreaterThan(0);632 expect(completedCount).toBeGreaterThan(0);
633 expect(screen.getByTestId("pending-status").textContent).toBe("idle");633 expect(screen.getByTestId("pending-status").textContent).toBe("idle");
634 },634 },
635 { timeout: 200 }635 { timeout: 200 },
636 );636 );
637637
638 // All clicks should be processed638 // All clicks should be processed
test/react.test.tsx+4-4
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { assertEquals } from "@std/assert";
1import { render, screen, waitFor } from "@testing-library/react";2import { render, screen, waitFor } from "@testing-library/react";
2import { userEvent } from "@testing-library/user-event";3import { userEvent } from "@testing-library/user-event";
3import { assertEquals } from "@std/assert";4import { useState } from "react";
4import { describe, test, expect, beforeEach, vi } from "vitest";5import { beforeEach, describe, expect, test, vi } from "vitest";
5import { MutationClient } from "../src/client.ts";6import { MutationClient } from "../src/client.ts";
6import { useMutate, createMutationButton } from "../src/react.ts";7import { createMutationButton, useMutate } from "../src/react.ts";
7import type { Mutation } from "../src/types.ts";8import type { Mutation } from "../src/types.ts";
8import { useState } from "react";
99
10// Helper to create a test mutation client10// Helper to create a test mutation client
11function createTestClient() {11function createTestClient() {
test/tanstack-query-helpers.test.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { assertEquals } from "@std/assert";1import { assertEquals } from "@std/assert";
2import { test } from "vitest";
3import { QueryClient, queryOptions } from "@tanstack/react-query";2import { QueryClient, queryOptions } from "@tanstack/react-query";
3import { test } from "vitest";
4import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";4import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";
55
6interface TestData {6interface TestData {