| author | |
| committer | |
| log | 005380d2610353262fa5acd170352dbbf3e4e6c2 |
| tree | 2f66ae1a07141eefd534d1e8885916242bf2f66d |
| parent | 4591d794aff944fbb510d589770021c9ccb60806 |
| signature |
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 @@ |
| 1 | import { createMutationButton, MutationClient, queryClientOptimisticHelpers, useMutate } from "@clo/react-mutation"; | ||
| 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | import { | ||
| 3 | createMutationButton, | ||
| 4 | MutationClient, | ||
| 5 | queryClientOptimisticHelpers, | ||
| 6 | useMutate, | ||
| 7 | } from "@clo/react-mutation"; | ||
| 8 | import { queryOptions as queryOptions } from "@tanstack/react-query"; | 3 | import { queryOptions as queryOptions } from "@tanstack/react-query"; |
| 9 | import { useSuspenseQuery } from "@tanstack/react-query"; | 4 | import { useSuspenseQuery } from "@tanstack/react-query"; |
| 10 | import { QueryKeyAndFn } from "../../src/tanstack-query.ts"; | 5 | import { 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 |
| 10 | features. Additionally, coworkers using AI agents continue to propagate bad | 10 | features. Additionally, coworkers using AI agents continue to propagate bad |
| 11 | patterns and verbose code that is hard to review. | 11 | patterns and verbose code that is hard to review. |
| 12 | 12 | ||
| 13 | The primary gains React Mutation provides are | 13 | The primary gains React Mutation provides are: |
| 14 | 14 | ||
| 15 | - **Automatic result handling**. If a `useMutate` hook does not observe | 15 | - **Automatic result handling**. If a `useMutate` hook does not observe |
| 16 | `isError`, unhandled errors will be propagated to a global handler, which can | 16 | `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. The | 19 | UI without worrying about bugged error states. The |
| 20 | [built in helpers for React Query](#react-query-optimistic-helpers) shows | 20 | [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). |
| 23 | 23 | ||
| 24 | ## Setup | 24 | ## Setup |
| 25 | 25 | ||
| 26 | React Mutation starts with a `MutationClient`, which shares global state for an application. | 26 | React Mutation starts with a `MutationClient`, which shares global state for an application. |
| 27 | 27 | ||
| 28 | ```ts | 28 | ```ts |
| 29 | import { QueryClient } from "@tanstack/react-query"; | ||
| 30 | import { MutationClient } from "@clo/react-mutation"; | ||
| 31 | import { queryClientOptimisticHelpers, boundQueryClientGet } from "@clo/react-mutation"; | ||
| 32 | import { showToastUI } from "..."; | 29 | import { showToastUI } from "..."; |
| 30 | import { MutationClient } from "@clo/react-mutation"; | ||
| 31 | import { | ||
| 32 | boundQueryClientGet, | ||
| 33 | queryClientOptimisticHelpers, | ||
| 34 | } from "@clo/react-mutation"; | ||
| 35 | import { QueryClient } from "@tanstack/react-query"; | ||
| 33 | 36 | ||
| 34 | const queryClient = new QueryClient(); | 37 | const queryClient = new QueryClient(); |
| 35 | export const mutations = new MutationClient({ | 38 | export 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 within | 47 | // 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 pending | 52 | // 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 telemetry | 57 | 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. |
| 234 | 237 | ||
| 235 | ```tsx | 238 | ```tsx |
| 236 | const mutUpdateField = mutations.define({ | 239 | const 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 | ```tsx | 330 | ```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> | ||
| 332 | 336 | ||
| 333 | {/* Dynamic Arguments */} | 337 | {/* Dynamic Arguments */} |
| 334 | <MutationButton mutation={mutSendMessage} args={(e) => { | 338 | <MutationButton |
| 335 | if (Math.random() < 0.5) e.preventDefault(); // prevent the submit | 339 | 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 | }} |
| 340 | 344 | > | |
| 341 | 345 | Send Message | |
| 342 | 346 | </MutationButton> | |
| 343 | ## Batched Mutations | 347 | </>; |
| 344 | |||
| 345 | This is an advanced feature. Complete Documentation is pending. It is not recommended to use this. | ||
| 346 | |||
| 347 | Each call to the mutation applies new optimistic state on top of the previous, | ||
| 348 | and after a debounce / throttle, the new optimistic state is committed to the | ||
| 349 | API. UI never shows a pending state for these. This works great for toggle buttons | ||
| 350 | and any other state where you'd like to define an optimistic state | ||
| 351 | |||
| 352 | In many places, similar behavior can be achieved with standard mutations and its | ||
| 353 | `debounceMs` field. | ||
| 354 | |||
| 355 | ```tsx | ||
| 356 | const 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 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | 2 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | import type { MutationClientConfig } from "./client.ts"; | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; | 4 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | 5 | ||
| 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 | } |
| 139 | 139 | ||
| 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 | } |
| 145 | 145 | ||
| ... | @@ -263,9 +263,7 @@ export class BlockingMutation< | ... | @@ -263,9 +263,7 @@ export class BlockingMutation< |
| 263 | 263 | ||
| 264 | // Call global handler unless suppressed | 264 | // 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 callbacks | 405 | // 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 callbacks | 442 | // 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 added | 526 | // 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 | } |
| 613 | 605 | ||
| 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; |
| 617 | 608 | ||
| 618 | // Check if there are any listeners at time of enqueue | 609 | // 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 promises | 612 | // 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 promises | 622 | // 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 time | 625 | // 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 promises | 635 | // 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 time | 638 | // 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 @@ |
| 1 | import { | ||
| 2 | DebouncedMutation, | ||
| 3 | type DebouncedMutationOptions, | ||
| 4 | } from "./debounced.ts"; | ||
| 5 | import { BlockingMutation, type MutationOptions } from "./blocking.ts"; | 1 | import { BlockingMutation, type MutationOptions } from "./blocking.ts"; |
| 2 | import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; | ||
| 6 | import type { Mutation } from "./types.ts"; | 3 | import type { Mutation } from "./types.ts"; |
| 7 | 4 | ||
| 8 | export interface MutationClientConfig { | 5 | export interface MutationClientConfig { |
| ... | @@ -10,11 +7,12 @@ export interface MutationClientConfig { | ... | @@ -10,11 +7,12 @@ export interface MutationClientConfig { |
| 10 | optimisticHelpers: {}; | 7 | optimisticHelpers: {}; |
| 11 | } | 8 | } |
| 12 | 9 | ||
| 13 | export type MutationClientFromConfig<Config extends MutationClientConfig> = | 10 | export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient< |
| 14 | MutationClient<Config["context"], Config["optimisticHelpers"]>; | 11 | Config["context"], |
| 12 | Config["optimisticHelpers"] | ||
| 13 | >; | ||
| 15 | 14 | ||
| 16 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => | 15 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); |
| 17 | JSON.stringify(a) === JSON.stringify(b); | ||
| 18 | 16 | ||
| 19 | export interface MutationClientOptions< | 17 | export interface MutationClientOptions< |
| 20 | Context extends object, | 18 | Context extends object, |
src/debounced.ts+6-16| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | 2 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | import type { MutationClientConfig } from "./client.ts"; | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; | 4 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | 5 | ||
| 6 | export interface DebouncedMutationOptions< | 6 | export 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< |
| 321 | 319 | ||
| 322 | // Call global error handler unless suppressed | 320 | // 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)); |
| 526 | 522 | ||
| 527 | // Report success globally if any of the pending items requested it | 523 | // 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 callbacks | 551 | // 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 callbacks | 588 | // 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 @@ |
| 1 | export type { MutationOptions, OptimisticContext } from "./blocking.ts"; | 1 | export type { MutationOptions, OptimisticContext } from "./blocking.ts"; |
| 2 | export type { | ||
| 3 | DebouncedCommitContext, | ||
| 4 | DebouncedMutationOptions, | ||
| 5 | DebouncedOptimisticContext, | ||
| 6 | } from "./debounced.ts"; | ||
| 7 | export { | 2 | export { |
| 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"; |
| 13 | export type { Mutation, MutationEvent } from "./types.ts"; | 8 | export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts"; |
| 14 | export { | 9 | export { |
| 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"; |
| 20 | export type { Mutation, MutationEvent } from "./types.ts"; |
src/object-path.ts+3-4| ... | @@ -1,13 +1,12 @@ | ... | @@ -1,13 +1,12 @@ |
| 1 | export type AllObjectPaths<T, Filter = unknown> = T extends | 1 | export 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 | : []; |
| 9 | export type GetObjectPath<T, P extends unknown[]> = P extends | 8 | export 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; |
| 12 | 11 | ||
| 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 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 1 | import { | 2 | import { |
| 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"; |
| 10 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 11 | import type { Mutation, RunOptions } from "./types.ts"; | ||
| 12 | import { jsx } from "react/jsx-runtime"; | 11 | import { jsx } from "react/jsx-runtime"; |
| 12 | import type { Mutation, RunOptions } from "./types.ts"; | ||
| 13 | 13 | ||
| 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 | } |
| 177 | 175 | ||
| 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 for | 220 | // 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 names | 427 | // react devtools loves display names |
| 432 | bound.displayName = `MutationButton[${ | 428 | bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`; |
| 433 | Component.displayName ?? Component.name | ||
| 434 | }]`; | ||
| 435 | 429 | ||
| 436 | return bound; | 430 | return bound; |
| 437 | } | 431 | } |
src/tanstack-query.ts+5-21| ... | @@ -1,16 +1,6 @@ | ... | @@ -1,16 +1,6 @@ |
| 1 | import { | 1 | import { 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"; | ||
| 7 | import { | ||
| 8 | type AllObjectPaths, | ||
| 9 | type GetObjectPath, | ||
| 10 | getPath, | ||
| 11 | setPath, | ||
| 12 | } from "./object-path.ts"; | ||
| 13 | import type { OptimisticEvents } from "./client.ts"; | 2 | import type { OptimisticEvents } from "./client.ts"; |
| 3 | import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; | ||
| 14 | 4 | ||
| 15 | export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { | 5 | export 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; |
| 338 | 328 | ||
| 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; |
| 407 | 395 | ||
| 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; |
| 551 | 537 | ||
| 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 changed | 541 | // 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 @@ |
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | import type { MutationEvent } from "../src/types.ts"; | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | ||
| 5 | 5 | ||
| 6 | // Helper to create a test mutation client | 6 | // Helper to create a test mutation client |
| 7 | function createTestClient() { | 7 | function 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 |
| 116 | 116 | ||
| 117 | // Mutation should have been called despite no listeners | 117 | // 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 listeners | 120 | // 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 @@ |
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | import type { MutationEvent } from "../src/types.ts"; | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | ||
| 5 | 5 | ||
| 6 | // Helper to create a test mutation client | 6 | // Helper to create a test mutation client |
| 7 | function createTestClient() { | 7 | function 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")); |
| 732 | 732 | ||
| 733 | // Should have error event | 733 | // 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); |
| 820 | 818 | ||
| 821 | // Should have refetching event with result | 819 | // 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 @@ |
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | import type { MutationEvent } from "../src/types.ts"; | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | ||
| 5 | 5 | ||
| 6 | // Shared test store for optimistic updates | 6 | // Shared test store for optimistic updates |
| 7 | const testStore = new Map<string, number>(); | 7 | const 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 | }; |
| 283 | 283 | ||
| 284 | const result = setPath(obj, ["address", "city"], "LA"); | 284 | const result = setPath(obj, ["address", "city"], "LA"); |
| 285 | 285 | ||
| 286 | // Changed path should have new references | 286 | // Changed path should have new references |
| 287 | assertEquals(result === obj, false); // Root is new | 287 | assertEquals(result === obj, false); // Root is new |
| 288 | assertEquals(result.address === obj.address, false); // Address is new | 288 | assertEquals(result.address === obj.address, false); // Address is new |
| 289 | 289 | ||
| 290 | // Unchanged branches should share references | 290 | // Unchanged branches should share references |
| 291 | assertEquals(result.hobbies === obj.hobbies, true); // Same reference | 291 | assertEquals(result.hobbies === obj.hobbies, true); // Same reference |
| 292 | assertEquals(result.nested === obj.nested, true); // Same reference | 292 | 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"; |
| 7 | 7 | ||
| 8 | // Type testing utilities | 8 | // Type testing utilities |
| 9 | type Expect<T extends true> = T; | 9 | type Expect<T extends true> = T; |
| 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends | 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 |
| 11 | <T>() => T extends Y ? 1 | 11 | : 2 ? true |
| 12 | : 2 ? true | ||
| 13 | : false; | 12 | : false; |
| 14 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; | 13 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; |
| 15 | type IsAny<T> = 0 extends 1 & T ? true : false; | 14 | type 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); |
| 325 | type ArrayPushSignature< | 324 | type 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; |
| 331 | 329 | ||
| 332 | // This should accept individual items, not arrays | 330 | // 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"); |
| 359 | type IncrementSignature< | 357 | type IncrementSignature< |
| 360 | Data extends object, | 358 | Data extends object, |
| 361 | Path extends AllObjectPaths<Data>, | 359 | Path extends AllObjectPaths<Data>, |
| 362 | > = GetObjectPath<Data, Path> extends number | 360 | > = GetObjectPath<Data, Path> extends number ? (path: Path, amount?: number) => void |
| 363 | ? (path: Path, amount?: number) => void | ||
| 364 | : never; | 361 | : never; |
| 365 | 362 | ||
| 366 | declare const increment: IncrementSignature<TestData, ["count"]>; | 363 | declare 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 | >; |
| 400 | 397 | ||
| 401 | export type { | 398 | export 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 @@ |
| 1 | import { render, screen, waitFor } from "@testing-library/react"; | 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import { userEvent } from "@testing-library/user-event"; | 2 | import { userEvent } from "@testing-library/user-event"; |
| 3 | import { describe, test, expect, vi } from "vitest"; | ||
| 4 | import { MutationClient } from "../src/client.ts"; | ||
| 5 | import { useMutate, createMutationButton } from "../src/react.ts"; | ||
| 6 | import type { FC } from "react"; | 3 | import type { FC } from "react"; |
| 4 | import { describe, expect, test, vi } from "vitest"; | ||
| 5 | import { MutationClient } from "../src/client.ts"; | ||
| 6 | import { createMutationButton, useMutate } from "../src/react.ts"; | ||
| 7 | 7 | ||
| 8 | // Helper to create a test mutation client | 8 | // Helper to create a test mutation client |
| 9 | function createTestClient() { | 9 | function 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 | ); |
| 637 | 637 | ||
| 638 | // All clicks should be processed | 638 | // All clicks should be processed |
test/react.test.tsx+4-4| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import { assertEquals } from "@std/assert"; | ||
| 1 | import { render, screen, waitFor } from "@testing-library/react"; | 2 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import { userEvent } from "@testing-library/user-event"; | 3 | import { userEvent } from "@testing-library/user-event"; |
| 3 | import { assertEquals } from "@std/assert"; | 4 | import { useState } from "react"; |
| 4 | import { describe, test, expect, beforeEach, vi } from "vitest"; | 5 | import { beforeEach, describe, expect, test, vi } from "vitest"; |
| 5 | import { MutationClient } from "../src/client.ts"; | 6 | import { MutationClient } from "../src/client.ts"; |
| 6 | import { useMutate, createMutationButton } from "../src/react.ts"; | 7 | import { createMutationButton, useMutate } from "../src/react.ts"; |
| 7 | import type { Mutation } from "../src/types.ts"; | 8 | import type { Mutation } from "../src/types.ts"; |
| 8 | import { useState } from "react"; | ||
| 9 | 9 | ||
| 10 | // Helper to create a test mutation client | 10 | // Helper to create a test mutation client |
| 11 | function createTestClient() { | 11 | function createTestClient() { |
test/tanstack-query-helpers.test.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { assertEquals } from "@std/assert"; | 1 | import { assertEquals } from "@std/assert"; |
| 2 | import { test } from "vitest"; | ||
| 3 | import { QueryClient, queryOptions } from "@tanstack/react-query"; | 2 | import { QueryClient, queryOptions } from "@tanstack/react-query"; |
| 3 | import { test } from "vitest"; | ||
| 4 | import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; | 4 | import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; |
| 5 | 5 | ||
| 6 | interface TestData { | 6 | interface TestData { |