| 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 | import { createMutationButton, MutationClient, queryClientOptimisticHelpers, useMutate } from "@clo/react-mutation"; | |
| 1 | 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | import { | |
| 3 | createMutationButton, | |
| 4 | MutationClient, | |
| 5 | queryClientOptimisticHelpers, | |
| 6 | useMutate, | |
| 7 | } from "@clo/react-mutation"; | |
| 8 | 3 | import { queryOptions as queryOptions } from "@tanstack/react-query"; |
| 9 | 4 | import { useSuspenseQuery } from "@tanstack/react-query"; |
| 10 | 5 | import { QueryKeyAndFn } from "../../src/tanstack-query.ts"; |
jsr.json+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "1.0.0-beta.10", | |
| 3 | "version": "1.0.0-beta.11", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/mod.ts", |
| 6 | 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 | 10 | features. Additionally, coworkers using AI agents continue to propagate bad |
| 11 | 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 | 15 | - **Automatic result handling**. If a `useMutate` hook does not observe |
| 16 | 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 | 19 | UI without worrying about bugged error states. The |
| 20 | 20 | [built in helpers for React Query](#react-query-optimistic-helpers) shows |
| 21 | 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 | 24 | ## Setup |
| 25 | 25 | |
| 26 | 26 | React Mutation starts with a `MutationClient`, which shares global state for an application. |
| 27 | 27 | |
| 28 | 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 | 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 | 37 | const queryClient = new QueryClient(); |
| 35 | 38 | export const mutations = new MutationClient({ |
| ... | ... | @@ -40,12 +43,12 @@ export const mutations = new MutationClient({ |
| 40 | 43 | // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`) |
| 41 | 44 | get: (k: QueryKey) => client.getQueryData(k), |
| 42 | 45 | }, |
| 43 | ||
| 46 | ||
| 44 | 47 | // Optimistic helpers are a second type of context, only available within |
| 45 | 48 | // optimistic update functions. These functions are bound to each mutation, |
| 46 | 49 | // which means they can handle automatic rollbacks and query invalidation. |
| 47 | 50 | getOptimisticHelpers: queryClientOptimisticHelpers(queryClient), |
| 48 | ||
| 51 | ||
| 49 | 52 | // When call sites do not opt into handling errors, or a pending |
| 50 | 53 | // mutation hook is unmounted, errors are sent to this function. |
| 51 | 54 | // An example is to bind this to global a UI toast. |
| ... | ... | @@ -53,7 +56,7 @@ export const mutations = new MutationClient({ |
| 53 | 56 | showToastUI("error", userFriendlyErrorMessage); |
| 54 | 57 | console.error(error); // or send to telemetry |
| 55 | 58 | }, |
| 56 | ||
| 59 | ||
| 57 | 60 | // Similarly, when call sites do opt into handling success. |
| 58 | 61 | reportSuccess(userFriendlySuccessMessage: string) { |
| 59 | 62 | showToastUI("success", userFriendlyErrorMessage); |
| ... | ... | @@ -234,19 +237,18 @@ anything, `snapshot` can be used to detect no-op mutations. |
| 234 | 237 | |
| 235 | 238 | ```tsx |
| 236 | 239 | const mutUpdateField = mutations.define({ |
| 237 | async mutate(id: string, value: string) { /* mutation */ }, | |
| 238 | ||
| 240 | async mutate(id: string, value: string) {/* mutation */}, | |
| 241 | ||
| 239 | 242 | optimistic({ args: [id, value], helpers }) { |
| 240 | 243 | helpers.objSet(queryItem(id), ["value"], value); |
| 241 | 244 | }, |
| 242 | ||
| 245 | ||
| 243 | 246 | // called once before `optimistic` and once after. if the values are equal, |
| 244 | 247 | // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`) |
| 245 | 248 | // (defaulting to a json-based deep equal check, customize in MutationClient) |
| 246 | 249 | snapshot({ args: [id], get }) { |
| 247 | 250 | return get(queryItem(id))?.value; |
| 248 | } | |
| 249 | ||
| 251 | }, | |
| 250 | 252 | // (...describe and optionally debounce stuff...) |
| 251 | 253 | }); |
| 252 | 254 | ``` |
| ... | ... | @@ -328,56 +330,19 @@ It can now be used for easy mutations: |
| 328 | 330 | ```tsx |
| 329 | 331 | <> |
| 330 | 332 | {/* Static Arguments */} |
| 331 | <MutationButton mutation={mutToggleFollow} args={[ userId ]}>Follow</MutationButton> | |
| 333 | <MutationButton mutation={mutToggleFollow} args={[userId]}> | |
| 334 | Follow | |
| 335 | </MutationButton> | |
| 332 | 336 | |
| 333 | 337 | {/* 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 | ||
| 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 | }); | |
| 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 | </>; | |
| 383 | 348 | ``` |
src/blocking.ts+13-24| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 1 | 2 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | 4 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * Argument to `defineBlocking`. |
| ... | ... | @@ -138,8 +138,8 @@ export class BlockingMutation< |
| 138 | 138 | } |
| 139 | 139 | |
| 140 | 140 | 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"; | |
| 143 | 143 | return JSON.stringify(k); |
| 144 | 144 | } |
| 145 | 145 | |
| ... | ... | @@ -263,9 +263,7 @@ export class BlockingMutation< |
| 263 | 263 | |
| 264 | 264 | // Call global handler unless suppressed |
| 265 | 265 | if (!suppressGlobalError) { |
| 266 | const message = `Failed to ${this.describe(...args)}: ${ | |
| 267 | errMessage(error) | |
| 268 | }`; | |
| 266 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | |
| 269 | 267 | this.#client.reportError(message, error); |
| 270 | 268 | } |
| 271 | 269 | }); |
| ... | ... | @@ -354,8 +352,7 @@ export class BlockingMutation< |
| 354 | 352 | expired = true; |
| 355 | 353 | let next; |
| 356 | 354 | while ( |
| 357 | next = | |
| 358 | channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | |
| 355 | next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | |
| 359 | 356 | ) { |
| 360 | 357 | next(); |
| 361 | 358 | } |
| ... | ... | @@ -408,9 +405,7 @@ export class BlockingMutation< |
| 408 | 405 | // Report any errors from refetch or callbacks |
| 409 | 406 | results.forEach((result) => { |
| 410 | 407 | 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)}`; | |
| 414 | 409 | this.#client.reportError(message, result.reason); |
| 415 | 410 | } |
| 416 | 411 | }); |
| ... | ... | @@ -447,9 +442,7 @@ export class BlockingMutation< |
| 447 | 442 | // Report any errors from refetch or callbacks |
| 448 | 443 | results.forEach((result) => { |
| 449 | 444 | 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)}`; | |
| 453 | 446 | this.#client.reportError(message, result.reason); |
| 454 | 447 | } |
| 455 | 448 | }); |
| ... | ... | @@ -533,8 +526,7 @@ export class BlockingMutation< |
| 533 | 526 | // Roll back the rollbacks we just added |
| 534 | 527 | let next; |
| 535 | 528 | while ( |
| 536 | next = | |
| 537 | channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | |
| 529 | next = channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | |
| 538 | 530 | ) { |
| 539 | 531 | next(); |
| 540 | 532 | } |
| ... | ... | @@ -611,13 +603,12 @@ export class BlockingMutation< |
| 611 | 603 | return; |
| 612 | 604 | } |
| 613 | 605 | |
| 614 | const { args, rollbackCount, pending, onSuccess } = | |
| 615 | channel.pendingDebounced; | |
| 606 | const { args, rollbackCount, pending, onSuccess } = channel.pendingDebounced; | |
| 616 | 607 | channel.pendingDebounced = null; |
| 617 | 608 | |
| 618 | 609 | // Check if there are any listeners at time of enqueue |
| 619 | 610 | const hasListeners = channel.listeners.size > 0; |
| 620 | ||
| 611 | ||
| 621 | 612 | // Create wrapper resolve/reject that resolves ALL pending promises |
| 622 | 613 | const { |
| 623 | 614 | promise: wrapperPromise, |
| ... | ... | @@ -630,7 +621,7 @@ export class BlockingMutation< |
| 630 | 621 | (result) => { |
| 631 | 622 | // Resolve all pending promises |
| 632 | 623 | pending.forEach((p) => p.resolve(result)); |
| 633 | ||
| 624 | ||
| 634 | 625 | // Check if there are any listeners at execution time |
| 635 | 626 | const hasListeners = channel.listeners.size > 0; |
| 636 | 627 | if (!hasListeners) { |
| ... | ... | @@ -643,13 +634,11 @@ export class BlockingMutation< |
| 643 | 634 | (error) => { |
| 644 | 635 | // Reject all pending promises |
| 645 | 636 | pending.forEach((p) => p.reject(error)); |
| 646 | ||
| 637 | ||
| 647 | 638 | // Check if there are any listeners at execution time |
| 648 | 639 | const hasListeners = channel.listeners.size > 0; |
| 649 | 640 | if (!hasListeners) { |
| 650 | const message = `Failed to ${this.describe(...args)}: ${ | |
| 651 | errMessage(error) | |
| 652 | }`; | |
| 641 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | |
| 653 | 642 | this.#client.reportError(message, error); |
| 654 | 643 | } |
| 655 | 644 | }, |
src/client.ts+6-8| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | import { | |
| 2 | DebouncedMutation, | |
| 3 | type DebouncedMutationOptions, | |
| 4 | } from "./debounced.ts"; | |
| 5 | 1 | import { BlockingMutation, type MutationOptions } from "./blocking.ts"; |
| 2 | import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; | |
| 6 | 3 | import type { Mutation } from "./types.ts"; |
| 7 | 4 | |
| 8 | 5 | export interface MutationClientConfig { |
| ... | ... | @@ -10,11 +7,12 @@ export interface MutationClientConfig { |
| 10 | 7 | optimisticHelpers: {}; |
| 11 | 8 | } |
| 12 | 9 | |
| 13 | export type MutationClientFromConfig<Config extends MutationClientConfig> = | |
| 14 | MutationClient<Config["context"], Config["optimisticHelpers"]>; | |
| 10 | export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient< | |
| 11 | Config["context"], | |
| 12 | Config["optimisticHelpers"] | |
| 13 | >; | |
| 15 | 14 | |
| 16 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => | |
| 17 | JSON.stringify(a) === JSON.stringify(b); | |
| 15 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); | |
| 18 | 16 | |
| 19 | 17 | export interface MutationClientOptions< |
| 20 | 18 | Context extends object, |
src/debounced.ts+6-16| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 1 | 2 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | 4 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 5 | 5 | |
| 6 | 6 | export interface DebouncedMutationOptions< |
| 7 | 7 | Args extends unknown[], |
| ... | ... | @@ -288,9 +288,7 @@ export class DebouncedMutation< |
| 288 | 288 | ); |
| 289 | 289 | } |
| 290 | 290 | 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)}`; | |
| 294 | 292 | this.#client.reportError(message, error); |
| 295 | 293 | }); |
| 296 | 294 | } |
| ... | ... | @@ -321,9 +319,7 @@ export class DebouncedMutation< |
| 321 | 319 | |
| 322 | 320 | // Call global error handler unless suppressed |
| 323 | 321 | if (!suppressGlobalError) { |
| 324 | const message = `Failed to ${this.describe(...args)}: ${ | |
| 325 | errMessage(error) | |
| 326 | }`; | |
| 322 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | |
| 327 | 323 | this.#client.reportError(message, error); |
| 328 | 324 | } |
| 329 | 325 | }); |
| ... | ... | @@ -525,9 +521,7 @@ export class DebouncedMutation< |
| 525 | 521 | pendingItems.forEach(({ resolve }) => resolve(result)); |
| 526 | 522 | |
| 527 | 523 | // 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); | |
| 531 | 525 | if (shouldReportSuccess) { |
| 532 | 526 | const message = this.#describeResult( |
| 533 | 527 | firstArgs, |
| ... | ... | @@ -557,9 +551,7 @@ export class DebouncedMutation< |
| 557 | 551 | // Report any errors from refetch or callbacks |
| 558 | 552 | results.forEach((result) => { |
| 559 | 553 | 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)}`; | |
| 563 | 555 | this.#client.reportError(message, result.reason); |
| 564 | 556 | } |
| 565 | 557 | }); |
| ... | ... | @@ -596,9 +588,7 @@ export class DebouncedMutation< |
| 596 | 588 | // Report any errors from refetch or callbacks |
| 597 | 589 | results.forEach((result) => { |
| 598 | 590 | 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)}`; | |
| 602 | 592 | this.#client.reportError(message, result.reason); |
| 603 | 593 | } |
| 604 | 594 | }); |
src/mod.ts+2-6| ... | ... | @@ -1,16 +1,11 @@ |
| 1 | 1 | export type { MutationOptions, OptimisticContext } from "./blocking.ts"; |
| 2 | export type { | |
| 3 | DebouncedCommitContext, | |
| 4 | DebouncedMutationOptions, | |
| 5 | DebouncedOptimisticContext, | |
| 6 | } from "./debounced.ts"; | |
| 7 | 2 | export { |
| 8 | 3 | MutationClient, |
| 9 | 4 | type MutationClientConfig, |
| 10 | 5 | type MutationClientFromConfig, |
| 11 | 6 | type MutationClientOptions, |
| 12 | 7 | } from "./client.ts"; |
| 13 | export type { Mutation, MutationEvent } from "./types.ts"; | |
| 8 | export type { DebouncedCommitContext, DebouncedMutationOptions, DebouncedOptimisticContext } from "./debounced.ts"; | |
| 14 | 9 | export { |
| 15 | 10 | createMutationButton, |
| 16 | 11 | type MutationButtonComponent, |
| ... | ... | @@ -22,3 +17,4 @@ export { |
| 22 | 17 | type UseMutateResultBase, |
| 23 | 18 | type UseMutateSuccess, |
| 24 | 19 | } from "./react.ts"; |
| 20 | export type { Mutation, MutationEvent } from "./types.ts"; |
src/object-path.ts+3-4| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | export type AllObjectPaths<T, Filter = unknown> = T extends | |
| 2 | ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>] | |
| 1 | export type AllObjectPaths<T, Filter = unknown> = T extends ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>] | |
| 3 | 2 | : T extends object ? |
| 4 | 3 | | { |
| 5 | 4 | [K in keyof T]-?: [K, ...AllObjectPaths<T[K]>]; |
| 6 | 5 | }[keyof T] |
| 7 | 6 | | [] |
| 8 | 7 | : []; |
| 9 | export type GetObjectPath<T, P extends unknown[]> = P extends | |
| 10 | [infer K extends keyof T, ...infer Rest] ? GetObjectPath<T[K], Rest> | |
| 8 | export type GetObjectPath<T, P extends unknown[]> = P extends [infer K extends keyof T, ...infer Rest] | |
| 9 | ? GetObjectPath<T[K], Rest> | |
| 11 | 10 | : T; |
| 12 | 11 | |
| 13 | 12 | /** Get an object path property */ |
src/react.ts+13-19| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 1 | 2 | import { |
| 2 | 3 | type FC, |
| 3 | 4 | type MouseEvent, |
| ... | ... | @@ -7,9 +8,8 @@ import { |
| 7 | 8 | useEffect, |
| 8 | 9 | useState, |
| 9 | 10 | } from "react"; |
| 10 | import { message as errMessage } from "@clo/lib/error.ts"; | |
| 11 | import type { Mutation, RunOptions } from "./types.ts"; | |
| 12 | 11 | import { jsx } from "react/jsx-runtime"; |
| 12 | import type { Mutation, RunOptions } from "./types.ts"; | |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | 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 | 170 | if (!error) return undefined; |
| 171 | 171 | const mutation = this.mutation; |
| 172 | 172 | 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)}`; | |
| 176 | 174 | } |
| 177 | 175 | |
| 178 | 176 | run(...args: Args) { |
| ... | ... | @@ -212,8 +210,8 @@ class Observer<Args extends unknown[], Result> { |
| 212 | 210 | isPending: status === "mutating" || status === "refetching", |
| 213 | 211 | isSuccess: hasResult && !hasError, |
| 214 | 212 | isError: hasError, |
| 215 | isOptimisticData: status === "waiting" || status === "mutating" || | |
| 216 | status === "refetching", | |
| 213 | isOptimisticData: status === "waiting" || status === "mutating" | |
| 214 | || status === "refetching", | |
| 217 | 215 | args: hasError || hasResult ? undefined : this.state.args, |
| 218 | 216 | }); |
| 219 | 217 | }, |
| ... | ... | @@ -222,10 +220,10 @@ class Observer<Args extends unknown[], Result> { |
| 222 | 220 | // Use global error/success handling if this usage of the hook doesn't check for |
| 223 | 221 | // errors or success. This makes it act pretty awesome in terms of defaults. |
| 224 | 222 | // 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"); | |
| 229 | 227 | const promise = mutation.runAsPromise(...args) |
| 230 | 228 | .then((result) => { |
| 231 | 229 | if (!watchesSuccess && mutation.describeResult) { |
| ... | ... | @@ -237,9 +235,7 @@ class Observer<Args extends unknown[], Result> { |
| 237 | 235 | }); |
| 238 | 236 | promise.catch((err) => { |
| 239 | 237 | if (!watchesError) { |
| 240 | const message = `Failed to ${mutation.describe(...args)}: ${ | |
| 241 | errMessage(err) | |
| 242 | }`; | |
| 238 | const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; | |
| 243 | 239 | mutation.client.reportError(message, err); |
| 244 | 240 | } |
| 245 | 241 | }); |
| ... | ... | @@ -289,8 +285,8 @@ class Observer<Args extends unknown[], Result> { |
| 289 | 285 | isPending: status === "mutating" || status === "refetching", |
| 290 | 286 | isSuccess: hasResult && !hasError, |
| 291 | 287 | isError: hasError, |
| 292 | isOptimisticData: status === "waiting" || status === "mutating" || | |
| 293 | status === "refetching", | |
| 288 | isOptimisticData: status === "waiting" || status === "mutating" | |
| 289 | || status === "refetching", | |
| 294 | 290 | args: hasError || hasResult ? undefined : this.state.args, |
| 295 | 291 | }); |
| 296 | 292 | }, |
| ... | ... | @@ -429,9 +425,7 @@ export function createMutationButton<Props>( |
| 429 | 425 | // back into unspecified generics. |
| 430 | 426 | .bind(null, Component) as MutationButtonComponent<BareProps>; |
| 431 | 427 | // react devtools loves display names |
| 432 | bound.displayName = `MutationButton[${ | |
| 433 | Component.displayName ?? Component.name | |
| 434 | }]`; | |
| 428 | bound.displayName = `MutationButton[${Component.displayName ?? Component.name}]`; | |
| 435 | 429 | |
| 436 | 430 | return bound; |
| 437 | 431 | } |
src/tanstack-query.ts+5-21| ... | ... | @@ -1,16 +1,6 @@ |
| 1 | import { | |
| 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"; | |
| 1 | import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query"; | |
| 13 | 2 | import type { OptimisticEvents } from "./client.ts"; |
| 3 | import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; | |
| 14 | 4 | |
| 15 | 5 | export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { |
| 16 | 6 | queryKey: Key; |
| ... | ... | @@ -336,9 +326,7 @@ class TanstackQueryOptimisticHelpers { |
| 336 | 326 | const { value: original, exists } = getPath(prev, path); |
| 337 | 327 | if (!exists || !Array.isArray(original)) return; |
| 338 | 328 | |
| 339 | const newArray = original.filter((item, index) => | |
| 340 | !removeFilter(item, index) | |
| 341 | ); | |
| 329 | const newArray = original.filter((item, index) => !removeFilter(item, index)); | |
| 342 | 330 | this.#set( |
| 343 | 331 | queryKey, |
| 344 | 332 | (obj) => obj ? setPath(obj, path, newArray as any) : obj, |
| ... | ... | @@ -405,9 +393,7 @@ class TanstackQueryOptimisticHelpers { |
| 405 | 393 | const { value: original, exists } = getPath(prev, path); |
| 406 | 394 | if (!exists || !Array.isArray(original)) return; |
| 407 | 395 | |
| 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); | |
| 411 | 397 | this.#set( |
| 412 | 398 | queryKey, |
| 413 | 399 | (obj) => obj ? setPath(obj, path, newArray as any) : obj, |
| ... | ... | @@ -549,9 +535,7 @@ class TanstackQueryOptimisticHelpers { |
| 549 | 535 | const prev = this.#get(queryKey); |
| 550 | 536 | if (!prev || !Array.isArray(prev)) return; |
| 551 | 537 | |
| 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); | |
| 555 | 539 | this.#set(queryKey, newArray); |
| 556 | 540 | this.#onRestore(() => { |
| 557 | 541 | // TODO: splice items back in case original changed |
test/blocking-debounce-edge-cases.test.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | |
| 2 | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | |
| 5 | 5 | |
| 6 | 6 | // Helper to create a test mutation client |
| 7 | 7 | function createTestClient() { |
| ... | ... | @@ -116,7 +116,7 @@ test("BlockingMutation - debounce: mutation still executes after all listeners u |
| 116 | 116 | |
| 117 | 117 | // Mutation should have been called despite no listeners |
| 118 | 118 | assertEquals(mutateCallCount, 1); |
| 119 | ||
| 119 | ||
| 120 | 120 | // Global success handler should be called since no local listeners |
| 121 | 121 | assertEquals(successes.length, 1); |
| 122 | 122 | assertEquals(successes[0], "Successfully processed test"); |
test/blocking.test.ts+3-7| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | |
| 2 | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | |
| 5 | 5 | |
| 6 | 6 | // Helper to create a test mutation client |
| 7 | 7 | function createTestClient() { |
| ... | ... | @@ -731,9 +731,7 @@ test("BlockingMutation - notifies error on mutation failure", async () => { |
| 731 | 731 | await assertRejects(() => mutation.runAsPromise("test")); |
| 732 | 732 | |
| 733 | 733 | // 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); | |
| 737 | 735 | assertEquals(errorEvents.length > 0, true); |
| 738 | 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 | 817 | await delay(20); |
| 820 | 818 | |
| 821 | 819 | // 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"); | |
| 825 | 821 | assertEquals(refetchingEvents.length > 0, true); |
| 826 | 822 | assertEquals(refetchingEvents[0]?.result, "result-test"); |
| 827 | 823 | }); |
test/debounced.test.ts+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { assertEquals, assertRejects } from "@std/assert"; |
| 2 | import { test } from "vitest"; | |
| 2 | 3 | import { MutationClient } from "../src/client.ts"; |
| 3 | 4 | import type { MutationEvent } from "../src/types.ts"; |
| 4 | import { test } from "vitest"; | |
| 5 | 5 | |
| 6 | 6 | // Shared test store for optimistic updates |
| 7 | 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 | 282 | }; |
| 283 | 283 | |
| 284 | 284 | const result = setPath(obj, ["address", "city"], "LA"); |
| 285 | ||
| 285 | ||
| 286 | 286 | // Changed path should have new references |
| 287 | 287 | assertEquals(result === obj, false); // Root is new |
| 288 | 288 | assertEquals(result.address === obj.address, false); // Address is new |
| 289 | ||
| 289 | ||
| 290 | 290 | // Unchanged branches should share references |
| 291 | 291 | assertEquals(result.hobbies === obj.hobbies, true); // Same reference |
| 292 | 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 | 7 | |
| 8 | 8 | // Type testing utilities |
| 9 | 9 | type Expect<T extends true> = T; |
| 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends | |
| 11 | <T>() => T extends Y ? 1 | |
| 12 | : 2 ? true | |
| 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 | |
| 11 | : 2 ? true | |
| 13 | 12 | : false; |
| 14 | 13 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; |
| 15 | 14 | type IsAny<T> = 0 extends 1 & T ? true : false; |
| ... | ... | @@ -325,8 +324,7 @@ objSetCount(["count"], (n) => n + 1); |
| 325 | 324 | type ArrayPushSignature< |
| 326 | 325 | Data extends object, |
| 327 | 326 | 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 | |
| 330 | 328 | : never; |
| 331 | 329 | |
| 332 | 330 | // This should accept individual items, not arrays |
| ... | ... | @@ -359,8 +357,7 @@ arrayRemoveTags(["tags"], (tag) => tag === "alpha"); |
| 359 | 357 | type IncrementSignature< |
| 360 | 358 | Data extends object, |
| 361 | 359 | 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 | |
| 364 | 361 | : never; |
| 365 | 362 | |
| 366 | 363 | declare const increment: IncrementSignature<TestData, ["count"]>; |
| ... | ... | @@ -398,10 +395,4 @@ type NoAnyTest4 = Expect< |
| 398 | 395 | NotAny<GetObjectPath<TestData, ["settings", "theme"]>> |
| 399 | 396 | >; |
| 400 | 397 | |
| 401 | export type { | |
| 402 | ArrayPushSignature, | |
| 403 | ArrayRemoveSignature, | |
| 404 | IncrementSignature, | |
| 405 | ObjSetSignature, | |
| 406 | ToggleSignature, | |
| 407 | }; | |
| 398 | export type { ArrayPushSignature, ArrayRemoveSignature, IncrementSignature, ObjSetSignature, ToggleSignature }; |
test/react-button.test.tsx+4-4| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | 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 | 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 | 8 | // Helper to create a test mutation client |
| 9 | 9 | function createTestClient() { |
| ... | ... | @@ -632,7 +632,7 @@ describe("createMutationButton - Edge Cases", () => { |
| 632 | 632 | expect(completedCount).toBeGreaterThan(0); |
| 633 | 633 | expect(screen.getByTestId("pending-status").textContent).toBe("idle"); |
| 634 | 634 | }, |
| 635 | { timeout: 200 } | |
| 635 | { timeout: 200 }, | |
| 636 | 636 | ); |
| 637 | 637 | |
| 638 | 638 | // All clicks should be processed |
test/react.test.tsx+4-4| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | import { assertEquals } from "@std/assert"; | |
| 1 | 2 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | 3 | import { userEvent } from "@testing-library/user-event"; |
| 3 | import { assertEquals } from "@std/assert"; | |
| 4 | import { describe, test, expect, beforeEach, vi } from "vitest"; | |
| 4 | import { useState } from "react"; | |
| 5 | import { beforeEach, describe, expect, test, vi } from "vitest"; | |
| 5 | 6 | import { MutationClient } from "../src/client.ts"; |
| 6 | import { useMutate, createMutationButton } from "../src/react.ts"; | |
| 7 | import { createMutationButton, useMutate } from "../src/react.ts"; | |
| 7 | 8 | import type { Mutation } from "../src/types.ts"; |
| 8 | import { useState } from "react"; | |
| 9 | 9 | |
| 10 | 10 | // Helper to create a test mutation client |
| 11 | 11 | function createTestClient() { |
test/tanstack-query-helpers.test.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { assertEquals } from "@std/assert"; |
| 2 | import { test } from "vitest"; | |
| 3 | 2 | import { QueryClient, queryOptions } from "@tanstack/react-query"; |
| 3 | import { test } from "vitest"; | |
| 4 | 4 | import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; |
| 5 | 5 | |
| 6 | 6 | interface TestData { |