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 @@
1import { createMutationButton, MutationClient, queryClientOptimisticHelpers, useMutate } from "@clo/react-mutation";
12import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import {
3 createMutationButton,
4 MutationClient,
5 queryClientOptimisticHelpers,
6 useMutate,
7} from "@clo/react-mutation";
83import { queryOptions as queryOptions } from "@tanstack/react-query";
94import { useSuspenseQuery } from "@tanstack/react-query";
105import { QueryKeyAndFn } from "../../src/tanstack-query.ts";
jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.10",
3 "version": "1.0.0-beta.11",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./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
1010features. Additionally, coworkers using AI agents continue to propagate bad
1111patterns and verbose code that is hard to review.
1212
13The primary gains React Mutation provides are
13The primary gains React Mutation provides are:
1414
1515- **Automatic result handling**. If a `useMutate` hook does not observe
1616 `isError`, unhandled errors will be propagated to a global handler, which can
......@@ -19,17 +19,20 @@ The primary gains React Mutation provides are
1919 UI without worrying about bugged error states. The
2020 [built in helpers for React Query](#react-query-optimistic-helpers) shows
2121 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
2424## Setup
2525
2626React Mutation starts with a `MutationClient`, which shares global state for an application.
2727
2828```ts
29import { QueryClient } from "@tanstack/react-query";
30import { MutationClient } from "@clo/react-mutation";
31import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation";
3229import { 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
3437const queryClient = new QueryClient();
3538export const mutations = new MutationClient({
......@@ -40,12 +43,12 @@ export const mutations = new MutationClient({
4043 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`)
4144 get: (k: QueryKey) => client.getQueryData(k),
4245 },
43
46
4447 // Optimistic helpers are a second type of context, only available within
4548 // optimistic update functions. These functions are bound to each mutation,
4649 // which means they can handle automatic rollbacks and query invalidation.
4750 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
48
51
4952 // When call sites do not opt into handling errors, or a pending
5053 // mutation hook is unmounted, errors are sent to this function.
5154 // An example is to bind this to global a UI toast.
......@@ -53,7 +56,7 @@ export const mutations = new MutationClient({
5356 showToastUI("error", userFriendlyErrorMessage);
5457 console.error(error); // or send to telemetry
5558 },
56
59
5760 // Similarly, when call sites do opt into handling success.
5861 reportSuccess(userFriendlySuccessMessage: string) {
5962 showToastUI("success", userFriendlyErrorMessage);
......@@ -234,19 +237,18 @@ anything, `snapshot` can be used to detect no-op mutations.
234237
235238```tsx
236239const mutUpdateField = mutations.define({
237 async mutate(id: string, value: string) { /* mutation */ },
238
240 async mutate(id: string, value: string) {/* mutation */},
241
239242 optimistic({ args: [id, value], helpers }) {
240243 helpers.objSet(queryItem(id), ["value"], value);
241244 },
242
245
243246 // called once before `optimistic` and once after. if the values are equal,
244247 // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`)
245248 // (defaulting to a json-based deep equal check, customize in MutationClient)
246249 snapshot({ args: [id], get }) {
247250 return get(queryItem(id))?.value;
248 }
249
251 },
250252 // (...describe and optionally debounce stuff...)
251253});
252254```
......@@ -328,56 +330,19 @@ It can now be used for easy mutations:
328330```tsx
329331<>
330332 {/* Static Arguments */}
331 <MutationButton mutation={mutToggleFollow} args={[ userId ]}>Follow</MutationButton>
333 <MutationButton mutation={mutToggleFollow} args={[userId]}>
334 Follow
335 </MutationButton>
332336
333337 {/* Dynamic Arguments */}
334 <MutationButton mutation={mutSendMessage} args={(e) => {
335 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
336 return [userId, messageContent];
337 }}>Send Message</MutationButton>
338</>
339```
340
341
342
343## Batched Mutations
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});
338 <MutationButton
339 mutation={mutSendMessage}
340 args={(e) => {
341 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
342 return [userId, messageContent];
343 }}
344 >
345 Send Message
346 </MutationButton>
347</>;
383348```
src/blocking.ts+13-24
......@@ -1,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";
12import type { MutationClient, MutationClientFromConfig } from "./client.ts";
23import type { MutationClientConfig } from "./client.ts";
34import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
55
66/**
77 * Argument to `defineBlocking`.
......@@ -138,8 +138,8 @@ export class BlockingMutation<
138138 }
139139
140140 key(args: Args) {
141 const k = this.#options.key?.({ ...this.#client.context, args }) ??
142 "shared";
141 const k = this.#options.key?.({ ...this.#client.context, args })
142 ?? "shared";
143143 return JSON.stringify(k);
144144 }
145145
......@@ -263,9 +263,7 @@ export class BlockingMutation<
263263
264264 // Call global handler unless suppressed
265265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${
267 errMessage(error)
268 }`;
266 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
269267 this.#client.reportError(message, error);
270268 }
271269 });
......@@ -354,8 +352,7 @@ export class BlockingMutation<
354352 expired = true;
355353 let next;
356354 while (
357 next =
358 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
355 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
359356 ) {
360357 next();
361358 }
......@@ -408,9 +405,7 @@ export class BlockingMutation<
408405 // Report any errors from refetch or callbacks
409406 results.forEach((result) => {
410407 if (result.status === "rejected") {
411 const message = `Failed to refetch after ${
412 this.describe(...args)
413 }: ${errMessage(result.reason)}`;
408 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
414409 this.#client.reportError(message, result.reason);
415410 }
416411 });
......@@ -447,9 +442,7 @@ export class BlockingMutation<
447442 // Report any errors from refetch or callbacks
448443 results.forEach((result) => {
449444 if (result.status === "rejected") {
450 const message = `Failed to refetch after ${
451 this.describe(...args)
452 }: ${errMessage(result.reason)}`;
445 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
453446 this.#client.reportError(message, result.reason);
454447 }
455448 });
......@@ -533,8 +526,7 @@ export class BlockingMutation<
533526 // Roll back the rollbacks we just added
534527 let next;
535528 while (
536 next =
537 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
529 next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
538530 ) {
539531 next();
540532 }
......@@ -611,13 +603,12 @@ export class BlockingMutation<
611603 return;
612604 }
613605
614 const { args, rollbackCount, pending, onSuccess } =
615 channel.pendingDebounced;
606 const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced;
616607 channel.pendingDebounced = null;
617608
618609 // Check if there are any listeners at time of enqueue
619610 const hasListeners = channel.listeners.size > 0;
620
611
621612 // Create wrapper resolve/reject that resolves ALL pending promises
622613 const {
623614 promise: wrapperPromise,
......@@ -630,7 +621,7 @@ export class BlockingMutation<
630621 (result) => {
631622 // Resolve all pending promises
632623 pending.forEach((p) => p.resolve(result));
633
624
634625 // Check if there are any listeners at execution time
635626 const hasListeners = channel.listeners.size > 0;
636627 if (!hasListeners) {
......@@ -643,13 +634,11 @@ export class BlockingMutation<
643634 (error) => {
644635 // Reject all pending promises
645636 pending.forEach((p) => p.reject(error));
646
637
647638 // Check if there are any listeners at execution time
648639 const hasListeners = channel.listeners.size > 0;
649640 if (!hasListeners) {
650 const message = `Failed to ${this.describe(...args)}: ${
651 errMessage(error)
652 }`;
641 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
653642 this.#client.reportError(message, error);
654643 }
655644 },
src/client.ts+6-8
......@@ -1,8 +1,5 @@
1import {
2 DebouncedMutation,
3 type DebouncedMutationOptions,
4} from "./debounced.ts";
51import { BlockingMutation, type MutationOptions } from "./blocking.ts";
2import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
63import type { Mutation } from "./types.ts";
74
85export interface MutationClientConfig {
......@@ -10,11 +7,12 @@ export interface MutationClientConfig {
107 optimisticHelpers: {};
118}
129
13export type MutationClientFromConfig<Config extends MutationClientConfig> =
14 MutationClient<Config["context"], Config["optimisticHelpers"]>;
10export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient<
11 Config["context"],
12 Config["optimisticHelpers"]
13>;
1514
16const defaultDeepEquals = (a: unknown, b: unknown): boolean =>
17 JSON.stringify(a) === JSON.stringify(b);
15const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
1816
1917export interface MutationClientOptions<
2018 Context extends object,
src/debounced.ts+6-16
......@@ -1,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";
12import type { MutationClient, MutationClientFromConfig } from "./client.ts";
23import type { MutationClientConfig } from "./client.ts";
34import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
55
66export interface DebouncedMutationOptions<
77 Args extends unknown[],
......@@ -288,9 +288,7 @@ export class DebouncedMutation<
288288 );
289289 }
290290 this.#runAndReturn(args, true, undefined).catch((error) => {
291 const message = `Failed to ${this.describe(...args)}: ${
292 errMessage(error)
293 }`;
291 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
294292 this.#client.reportError(message, error);
295293 });
296294 }
......@@ -321,9 +319,7 @@ export class DebouncedMutation<
321319
322320 // Call global error handler unless suppressed
323321 if (!suppressGlobalError) {
324 const message = `Failed to ${this.describe(...args)}: ${
325 errMessage(error)
326 }`;
322 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
327323 this.#client.reportError(message, error);
328324 }
329325 });
......@@ -525,9 +521,7 @@ export class DebouncedMutation<
525521 pendingItems.forEach(({ resolve }) => resolve(result));
526522
527523 // Report success globally if any of the pending items requested it
528 const shouldReportSuccess = pendingItems.some((item) =>
529 item.reportSuccessGlobally
530 );
524 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
531525 if (shouldReportSuccess) {
532526 const message = this.#describeResult(
533527 firstArgs,
......@@ -557,9 +551,7 @@ export class DebouncedMutation<
557551 // Report any errors from refetch or callbacks
558552 results.forEach((result) => {
559553 if (result.status === "rejected") {
560 const message = `Failed to refetch after ${
561 this.describe(...firstArgs)
562 }: ${errMessage(result.reason)}`;
554 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
563555 this.#client.reportError(message, result.reason);
564556 }
565557 });
......@@ -596,9 +588,7 @@ export class DebouncedMutation<
596588 // Report any errors from refetch or callbacks
597589 results.forEach((result) => {
598590 if (result.status === "rejected") {
599 const message = `Failed to refetch after ${
600 this.describe(...firstArgs)
601 }: ${errMessage(result.reason)}`;
591 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
602592 this.#client.reportError(message, result.reason);
603593 }
604594 });
src/mod.ts+2-6
......@@ -1,16 +1,11 @@
11export type { MutationOptions, OptimisticContext } from "./blocking.ts";
2export type {
3 DebouncedCommitContext,
4 DebouncedMutationOptions,
5 DebouncedOptimisticContext,
6} from "./debounced.ts";
72export {
83 MutationClient,
94 type MutationClientConfig,
105 type MutationClientFromConfig,
116 type MutationClientOptions,
127} from "./client.ts";
13export type { Mutation, MutationEvent } from "./types.ts";
8export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts";
149export {
1510 createMutationButton,
1611 type MutationButtonComponent,
......@@ -22,3 +17,4 @@ export {
2217 type UseMutateResultBase,
2318 type UseMutateSuccess,
2419} from "./react.ts";
20export type { Mutation, MutationEvent } from "./types.ts";
src/object-path.ts+3-4
......@@ -1,13 +1,12 @@
1export type AllObjectPaths<T, Filter = unknown> = T extends
2 ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
1export type AllObjectPaths<T, Filter = unknown> = T extends ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
32 : T extends object ?
43 | {
54 [K in keyof T]-?: [K, ...AllObjectPaths<T[K]>];
65 }[keyof T]
76 | []
87 : [];
9export type GetObjectPath<T, P extends unknown[]> = P extends
10 [infer K extends keyof T, ...infer Rest] ? GetObjectPath<T[K], Rest>
8export type GetObjectPath<T, P extends unknown[]> = P extends [infer K extends keyof T, ...infer Rest]
9 ? GetObjectPath<T[K], Rest>
1110 : T;
1211
1312/** Get an object path property */
src/react.ts+13-19
......@@ -1,3 +1,4 @@
1import { message as errMessage } from "@clo/lib/error.ts";
12import {
23 type FC,
34 type MouseEvent,
......@@ -7,9 +8,8 @@ import {
78 useEffect,
89 useState,
910} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation, RunOptions } from "./types.ts";
1211import { jsx } from "react/jsx-runtime";
12import type { Mutation, RunOptions } from "./types.ts";
1313
1414/**
1515 * Subscribe to a mutation's status, as well as accessing a local `run` method.
......@@ -170,9 +170,7 @@ class Observer<Args extends unknown[], Result> {
170170 if (!error) return undefined;
171171 const mutation = this.mutation;
172172 if (!mutation || !this.state.args) return errMessage(error);
173 return `Failed to ${mutation.describe(...this.state.args)}: ${
174 errMessage(error)
175 }`;
173 return `Failed to ${mutation.describe(...this.state.args)}: ${errMessage(error)}`;
176174 }
177175
178176 run(...args: Args) {
......@@ -212,8 +210,8 @@ class Observer<Args extends unknown[], Result> {
212210 isPending: status === "mutating" || status === "refetching",
213211 isSuccess: hasResult && !hasError,
214212 isError: hasError,
215 isOptimisticData: status === "waiting" || status === "mutating" ||
216 status === "refetching",
213 isOptimisticData: status === "waiting" || status === "mutating"
214 || status === "refetching",
217215 args: hasError || hasResult ? undefined : this.state.args,
218216 });
219217 },
......@@ -222,10 +220,10 @@ class Observer<Args extends unknown[], Result> {
222220 // Use global error/success handling if this usage of the hook doesn't check for
223221 // errors or success. This makes it act pretty awesome in terms of defaults.
224222 // You don't have to worry about result UI, they'll surface exactly once.
225 const watchesError = this.watched.has("isError") ||
226 this.watched.has("error") || this.watched.has("errorMessage");
227 const watchesSuccess = this.watched.has("isSuccess") ||
228 this.watched.has("result");
223 const watchesError = this.watched.has("isError")
224 || this.watched.has("error") || this.watched.has("errorMessage");
225 const watchesSuccess = this.watched.has("isSuccess")
226 || this.watched.has("result");
229227 const promise = mutation.runAsPromise(...args)
230228 .then((result) => {
231229 if (!watchesSuccess && mutation.describeResult) {
......@@ -237,9 +235,7 @@ class Observer<Args extends unknown[], Result> {
237235 });
238236 promise.catch((err) => {
239237 if (!watchesError) {
240 const message = `Failed to ${mutation.describe(...args)}: ${
241 errMessage(err)
242 }`;
238 const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`;
243239 mutation.client.reportError(message, err);
244240 }
245241 });
......@@ -289,8 +285,8 @@ class Observer<Args extends unknown[], Result> {
289285 isPending: status === "mutating" || status === "refetching",
290286 isSuccess: hasResult && !hasError,
291287 isError: hasError,
292 isOptimisticData: status === "waiting" || status === "mutating" ||
293 status === "refetching",
288 isOptimisticData: status === "waiting" || status === "mutating"
289 || status === "refetching",
294290 args: hasError || hasResult ? undefined : this.state.args,
295291 });
296292 },
......@@ -429,9 +425,7 @@ export function createMutationButton<Props>(
429425 // back into unspecified generics.
430426 .bind(null, Component) as MutationButtonComponent<BareProps>;
431427 // react devtools loves display names
432 bound.displayName = `MutationButton[${
433 Component.displayName ?? Component.name
434 }]`;
428 bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`;
435429
436430 return bound;
437431}
src/tanstack-query.ts+5-21
......@@ -1,16 +1,6 @@
1import {
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";
1import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query";
132import type { OptimisticEvents } from "./client.ts";
3import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts";
144
155export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
166 queryKey: Key;
......@@ -336,9 +326,7 @@ class TanstackQueryOptimisticHelpers {
336326 const { value: original, exists } = getPath(prev, path);
337327 if (!exists || !Array.isArray(original)) return;
338328
339 const newArray = original.filter((item, index) =>
340 !removeFilter(item, index)
341 );
329 const newArray = original.filter((item, index) => !removeFilter(item, index));
342330 this.#set(
343331 queryKey,
344332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
......@@ -405,9 +393,7 @@ class TanstackQueryOptimisticHelpers {
405393 const { value: original, exists } = getPath(prev, path);
406394 if (!exists || !Array.isArray(original)) return;
407395
408 const newArray = original.map((item, index) =>
409 filter(item, index) ? update(item) : item
410 );
396 const newArray = original.map((item, index) => filter(item, index) ? update(item) : item);
411397 this.#set(
412398 queryKey,
413399 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
......@@ -549,9 +535,7 @@ class TanstackQueryOptimisticHelpers {
549535 const prev = this.#get(queryKey);
550536 if (!prev || !Array.isArray(prev)) return;
551537
552 const newArray = prev.map((item, index) =>
553 (filter ? filter(item, index) : true) ? update(item) : item
554 );
538 const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item);
555539 this.#set(queryKey, newArray);
556540 this.#onRestore(() => {
557541 // TODO: splice items back in case original changed
test/blocking-debounce-edge-cases.test.ts+2-2
......@@ -1,7 +1,7 @@
11import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
23import { MutationClient } from "../src/client.ts";
34import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
66// Helper to create a test mutation client
77function createTestClient() {
......@@ -116,7 +116,7 @@ test("BlockingMutation - debounce: mutation still executes after all listeners u
116116
117117 // Mutation should have been called despite no listeners
118118 assertEquals(mutateCallCount, 1);
119
119
120120 // Global success handler should be called since no local listeners
121121 assertEquals(successes.length, 1);
122122 assertEquals(successes[0], "Successfully processed test");
test/blocking.test.ts+3-7
......@@ -1,7 +1,7 @@
11import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
23import { MutationClient } from "../src/client.ts";
34import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
66// Helper to create a test mutation client
77function createTestClient() {
......@@ -731,9 +731,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
731731 await assertRejects(() => mutation.runAsPromise("test"));
732732
733733 // Should have error event
734 const errorEvents = tracker.events.filter((e) =>
735 e.status === "mutating" && e.error
736 );
734 const errorEvents = tracker.events.filter((e) => e.status === "mutating" && e.error);
737735 assertEquals(errorEvents.length > 0, true);
738736 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
739737});
......@@ -819,9 +817,7 @@ test("BlockingMutation - result is passed to notification on success", async ()
819817 await delay(20);
820818
821819 // Should have refetching event with result
822 const refetchingEvents = tracker.events.filter((e) =>
823 e.status === "refetching"
824 );
820 const refetchingEvents = tracker.events.filter((e) => e.status === "refetching");
825821 assertEquals(refetchingEvents.length > 0, true);
826822 assertEquals(refetchingEvents[0]?.result, "result-test");
827823});
test/debounced.test.ts+1-1
......@@ -1,7 +1,7 @@
11import { assertEquals, assertRejects } from "@std/assert";
2import { test } from "vitest";
23import { MutationClient } from "../src/client.ts";
34import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
55
66// Shared test store for optimistic updates
77const 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)"
282282 };
283283
284284 const result = setPath(obj, ["address", "city"], "LA");
285
285
286286 // Changed path should have new references
287287 assertEquals(result === obj, false); // Root is new
288288 assertEquals(result.address === obj.address, false); // Address is new
289
289
290290 // Unchanged branches should share references
291291 assertEquals(result.hobbies === obj.hobbies, true); // Same reference
292292 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";
77
88// Type testing utilities
99type Expect<T extends true> = T;
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
11 <T>() => T extends Y ? 1
12 : 2 ? true
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1
11 : 2 ? true
1312 : false;
1413type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
1514type IsAny<T> = 0 extends 1 & T ? true : false;
......@@ -325,8 +324,7 @@ objSetCount(["count"], (n) => n + 1);
325324type ArrayPushSignature<
326325 Data extends object,
327326 Path extends AllObjectPaths<Data>,
328> = GetObjectPath<Data, Path> extends readonly (infer T)[]
329 ? (path: Path, ...items: T[]) => void
327> = GetObjectPath<Data, Path> extends readonly (infer T)[] ? (path: Path, ...items: T[]) => void
330328 : never;
331329
332330// This should accept individual items, not arrays
......@@ -359,8 +357,7 @@ arrayRemoveTags(["tags"], (tag) => tag === "alpha");
359357type IncrementSignature<
360358 Data extends object,
361359 Path extends AllObjectPaths<Data>,
362> = GetObjectPath<Data, Path> extends number
363 ? (path: Path, amount?: number) => void
360> = GetObjectPath<Data, Path> extends number ? (path: Path, amount?: number) => void
364361 : never;
365362
366363declare const increment: IncrementSignature<TestData, ["count"]>;
......@@ -398,10 +395,4 @@ type NoAnyTest4 = Expect<
398395 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>
399396>;
400397
401export type {
402 ArrayPushSignature,
403 ArrayRemoveSignature,
404 IncrementSignature,
405 ObjSetSignature,
406 ToggleSignature,
407};
398export type { ArrayPushSignature, ArrayRemoveSignature, IncrementSignature, ObjSetSignature, ToggleSignature };
test/react-button.test.tsx+4-4
......@@ -1,9 +1,9 @@
11import { render, screen, waitFor } from "@testing-library/react";
22import { 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";
63import 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
88// Helper to create a test mutation client
99function createTestClient() {
......@@ -632,7 +632,7 @@ describe("createMutationButton - Edge Cases", () => {
632632 expect(completedCount).toBeGreaterThan(0);
633633 expect(screen.getByTestId("pending-status").textContent).toBe("idle");
634634 },
635 { timeout: 200 }
635 { timeout: 200 },
636636 );
637637
638638 // All clicks should be processed
test/react.test.tsx+4-4
......@@ -1,11 +1,11 @@
1import { assertEquals } from "@std/assert";
12import { render, screen, waitFor } from "@testing-library/react";
23import { userEvent } from "@testing-library/user-event";
3import { assertEquals } from "@std/assert";
4import { describe, test, expect, beforeEach, vi } from "vitest";
4import { useState } from "react";
5import { beforeEach, describe, expect, test, vi } from "vitest";
56import { MutationClient } from "../src/client.ts";
6import { useMutate, createMutationButton } from "../src/react.ts";
7import { createMutationButton, useMutate } from "../src/react.ts";
78import type { Mutation } from "../src/types.ts";
8import { useState } from "react";
99
1010// Helper to create a test mutation client
1111function createTestClient() {
test/tanstack-query-helpers.test.ts+1-1
......@@ -1,6 +1,6 @@
11import { assertEquals } from "@std/assert";
2import { test } from "vitest";
32import { QueryClient, queryOptions } from "@tanstack/react-query";
3import { test } from "vitest";
44import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";
55
66interface TestData {