| author | |
| committer | |
| log | 7ad627cb1d7df5aad122280d12e72244e86cb447 |
| tree | d855cb307d31a85f066bae1f003eb9dd5ceb0708 |
| parent | cc21b6dbb13e3b9425367a3e4a740ae8478a1633 |
| signature |
Closes #920 files changed, 1082 insertions(+), 79 deletions(-)
jsr.json+6-3| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "2.1.0", | |
| 3 | "version": "3.0.0", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/mod.ts", |
| 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", | |
| 7 | "./object-path.ts": "./src/object-path.ts" | |
| 6 | "./tanstack-query": "./src/tanstack-query.ts", | |
| 7 | "./object-path": "./src/object-path.ts" | |
| 8 | 8 | }, |
| 9 | 9 | "imports": { |
| 10 | 10 | "@tanstack/react-query": "npm:@tanstack/react-query@^5", |
| ... | ... | @@ -16,6 +16,9 @@ |
| 16 | 16 | "README.md", |
| 17 | 17 | "src/**/*", |
| 18 | 18 | "test/**/*" |
| 19 | ], | |
| 20 | "exclude": [ | |
| 21 | "src/play.ts" | |
| 19 | 22 | ] |
| 20 | 23 | }, |
| 21 | 24 | "license": "ISC" |
package.json+5| ... | ... | @@ -15,6 +15,11 @@ |
| 15 | 15 | "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", |
| 16 | 16 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" |
| 17 | 17 | }, |
| 18 | "exports": { | |
| 19 | ".": "./src/mod.ts", | |
| 20 | "./tanstack-query": "./src/tanstack-query.ts", | |
| 21 | "./object-path": "./src/object-path.ts" | |
| 22 | }, | |
| 18 | 23 | "devDependencies": { |
| 19 | 24 | "@tanstack/react-query": "^5.90.20", |
| 20 | 25 | "@testing-library/react": "^16.3.2", |
readme.changes.md+28| ... | ... | @@ -1,5 +1,32 @@ |
| 1 | 1 | # notable changes in React Mutation |
| 2 | 2 | |
| 3 | ## v3 | |
| 4 | ||
| 5 | ### breaking | |
| 6 | ||
| 7 | - `id` is now required for all mutations. It must be unique per client; | |
| 8 | duplicate registration throws in production and replaces the previous | |
| 9 | registration in development (hot reload re-runs `define`). | |
| 10 | - mutation arguments must be JSON serializable (`Json[]`) | |
| 11 | - `handleUnauthenticated` and `onUnauthenticated` now receive a serializable | |
| 12 | `MutationAction` (`{ id, description, args }`) instead of a description string | |
| 13 | ||
| 14 | ### features | |
| 15 | ||
| 16 | - authentication integration for mutations (`auth: true`, `userContext`, | |
| 17 | `isAllowed`, `handleUnauthenticated`) | |
| 18 | - auth scopes for permission gates: declare `authScopes` on the client and | |
| 19 | `auth: "<scope>"` on mutations | |
| 20 | - `MutationClient.run(action)` re-runs a stored `MutationAction`, enabling | |
| 21 | sign-in flows that resume the blocked mutation | |
| 22 | - `MutationButton` hides non-allowed mutations that cannot route to the | |
| 23 | sign-in flow; opt out with `showNotAllowed` | |
| 24 | ||
| 25 | ### bugfixes | |
| 26 | ||
| 27 | - `runAsHeadlessPromise` correctly rethrows the underlying error | |
| 28 | - clarify some intent in the readme | |
| 29 | ||
| 3 | 30 | ## v2.1.0 |
| 4 | 31 | |
| 5 | 32 | ### features |
| ... | ... | @@ -14,6 +41,7 @@ |
| 14 | 41 | ### bugfixes |
| 15 | 42 | |
| 16 | 43 | - typescript violation not passing client context to optimistic handlers |
| 44 | - `onSuccess` prop split into `onSuccessUi` and `onSuccessData`. but it didn't even work before so this isn't breaking. | |
| 17 | 45 | |
| 18 | 46 | ## v2 |
| 19 | 47 |
readme.md+79-5| ... | ... | @@ -28,10 +28,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an |
| 28 | 28 | ```ts |
| 29 | 29 | import { showToastUI } from "..."; |
| 30 | 30 | import { MutationClient } from "@clo/react-mutation"; |
| 31 | import { | |
| 32 | boundQueryClientGet, | |
| 33 | queryClientOptimisticHelpers, | |
| 34 | } from "@clo/react-mutation"; | |
| 31 | import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query"; | |
| 35 | 32 | import { QueryClient } from "@tanstack/react-query"; |
| 36 | 33 | |
| 37 | 34 | const queryClient = new QueryClient(); |
| ... | ... | @@ -39,7 +36,7 @@ export const mutations = new MutationClient({ |
| 39 | 36 | // All properties in `context` are available within every function. |
| 40 | 37 | context: { |
| 41 | 38 | client: queryClient, |
| 42 | get: boundQueryClientGet(client), | |
| 39 | get: boundQueryClientGet(queryClient), | |
| 43 | 40 | |
| 44 | 41 | // Can add any easy helpers for your codebase. |
| 45 | 42 | navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., |
| ... | ... | @@ -62,6 +59,33 @@ export const mutations = new MutationClient({ |
| 62 | 59 | reportSuccess(userFriendlySuccessMessage: string) { |
| 63 | 60 | showToastUI("success", userFriendlyErrorMessage); |
| 64 | 61 | }, |
| 62 | ||
| 63 | // Optionally, your session system can be integrated to provide `auth: true` | |
| 64 | // mutations that require a sign in before enabling. When a mutation cannot | |
| 65 | // be performed due to missing auth, its `isAllowed` field reads false. | |
| 66 | userContext: reactiveFromQueryCache( | |
| 67 | queryClient, | |
| 68 | queryCurrentUser, | |
| 69 | (user) => user ? { user } : null, | |
| 70 | ), | |
| 71 | // Optionally, on top of `userContext`, custom subsets of authentication can be | |
| 72 | // defined for different permission levels, for example admin-only. This is used | |
| 73 | // at the call site with `auth: "admin"`. | |
| 74 | authScopes: { | |
| 75 | admin: reactiveFromQueryCache(queryClient, queryCurrentUser, (user) => !!user?.isAdmin), | |
| 76 | }, | |
| 77 | // On top of `userContext`, the session system can integrate its login flow to | |
| 78 | // the mutation system. When configured, all authenticated mutations will be | |
| 79 | // marked enabled but `!isAllowed`, except ones in scopes (so an admin | |
| 80 | // mutation is still disabled and not allowed). When triggering a mutation, it | |
| 81 | // is routed to this function instead. | |
| 82 | handleUnauthenticated(action, mutation) { | |
| 83 | // `action` is serializable. Could commit it to `sessionStorage` to survive | |
| 84 | // a full-page sign-in flow, for example. | |
| 85 | openLoginModal(`Sign in to ${action.description}`, () => { | |
| 86 | mutations.run(action); | |
| 87 | }); | |
| 88 | }, | |
| 65 | 89 | }); |
| 66 | 90 | ``` |
| 67 | 91 | |
| ... | ... | @@ -76,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... }); |
| 76 | 100 | |
| 77 | 101 | // The convention is to name handlers starting with `mut` |
| 78 | 102 | const mutDeleteItem = mutations.define({ |
| 103 | // A stable identifier, unique per application. | |
| 104 | id: "item/delete", | |
| 79 | 105 | // `mutate` comes first (for type inference), and |
| 80 | 106 | // is only worried about syncing with the backend. |
| 81 | 107 | async mutate(id: string) { |
| ... | ... | @@ -217,6 +243,7 @@ will override the earlier calls by rolling back the optimistic state. |
| 217 | 243 | |
| 218 | 244 | ```tsx |
| 219 | 245 | const mutUpdateField = mutations.define({ |
| 246 | id: "item/update-field", | |
| 220 | 247 | async mutate(id: string, value: string) { /* mutation */ }, |
| 221 | 248 | optimistic({ args: [id, value], helpers }) { |
| 222 | 249 | helpers.objSet(queryItem(id), ["value"], value); |
| ... | ... | @@ -252,6 +279,7 @@ anything, `snapshot` can be used to detect no-op mutations. |
| 252 | 279 | |
| 253 | 280 | ```tsx |
| 254 | 281 | const mutUpdateField = mutations.define({ |
| 282 | id: "item/update-field", | |
| 255 | 283 | async mutate(id: string, value: string) {/* mutation */}, |
| 256 | 284 | |
| 257 | 285 | optimistic({ args: [id, value], helpers }) { |
| ... | ... | @@ -284,6 +312,8 @@ The `useMutate(null | Mutation)` react hook returns an object with the following |
| 284 | 312 | - `run` (Function) this starts the mutation. |
| 285 | 313 | - `clear` (Function) clear the status of sucess or error states. |
| 286 | 314 | - `isPending` (boolean) if a loading indicator should be visible. |
| 315 | - `isDisabled` (boolean) if the underlying form/button should be disabled | |
| 316 | - `isAllowed` (boolean) if the mutation is allowed considering authentication and pre-checks | |
| 287 | 317 | - `isSuccess` (boolean) if the mutation has succeeded. |
| 288 | 318 | - `result` (Result or undefined) the successful result of the mutation. |
| 289 | 319 | - `isError` (boolean) if the mutation failed. |
| ... | ... | @@ -361,3 +391,47 @@ It can now be used for easy mutations: |
| 361 | 391 | </MutationButton> |
| 362 | 392 | </>; |
| 363 | 393 | ``` |
| 394 | ||
| 395 | ## Authenticated Mutations | |
| 396 | ||
| 397 | Once the `MutationClient` is connected to the application's authentication | |
| 398 | system, mutations themselves can declare `auth: true`. This does two things: | |
| 399 | ||
| 400 | - `useMutate` and mutation buttons read `isAllowed: false` while signed out. | |
| 401 | Without a `handleUnauthenticated` handler they also disable; with one they | |
| 402 | stay enabled so a click can route to the sign-in flow. | |
| 403 | - The mutation implementation is given the `UserContext` to utilize. | |
| 404 | ||
| 405 | `describe` is the one function whose user context is nullable: it also runs | |
| 406 | while signed out to build the `action.description` given to the sign-in flow. | |
| 407 | ||
| 408 | ```tsx | |
| 409 | const mutUpdateBio = mutations.define({ | |
| 410 | id: "user/update-bio", | |
| 411 | auth: true, | |
| 412 | async mutate(bio: string) { | |
| 413 | this.user; // if the user context, if needed | |
| 414 | }, | |
| 415 | optimistic({ args: [bio], helpers }) { | |
| 416 | helpers.objSet(queryCurrentUser(), ["bio"], bio); | |
| 417 | }, | |
| 418 | // (...the rest...) | |
| 419 | }); | |
| 420 | ``` | |
| 421 | ||
| 422 | Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped | |
| 423 | mutation whose scope is unsatisfied always disables; it is never routed to | |
| 424 | `handleUnauthenticated`. | |
| 425 | ||
| 426 | ```tsx | |
| 427 | const mutBanUser = mutations.define({ | |
| 428 | id: "user/ban", | |
| 429 | auth: "admin", | |
| 430 | async mutate(targetId: string) {/* mutation */}, | |
| 431 | // (...the rest...) | |
| 432 | }); | |
| 433 | ``` | |
| 434 | ||
| 435 | By default, `MutationButton` will hide non-allowed mutations that cannot route | |
| 436 | to the sign-in flow, which can be opted out by passing the `showNotAllowed` | |
| 437 | prop. |
src/client.ts+82-8| ... | ... | @@ -1,21 +1,30 @@ |
| 1 | 1 | import { BlockingMutation, type MutationOptions } from "./mutation.ts"; |
| 2 | import type { Mutation } from "./types.ts"; | |
| 2 | import type { Json, Mutation, MutationAction } from "./types.ts"; | |
| 3 | 3 | |
| 4 | 4 | export interface MutationClientConfig { |
| 5 | 5 | context: {}; |
| 6 | 6 | optimisticHelpers: {}; |
| 7 | userContext: {}; | |
| 8 | authScopes: string; | |
| 7 | 9 | } |
| 8 | 10 | |
| 9 | 11 | export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient< |
| 10 | 12 | Config["context"], |
| 11 | Config["optimisticHelpers"] | |
| 13 | Config["optimisticHelpers"], | |
| 14 | Config["userContext"], | |
| 15 | Config["authScopes"] | |
| 12 | 16 | >; |
| 13 | 17 | |
| 14 | 18 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); |
| 15 | 19 | |
| 20 | // Declared locally to avoid depending on node types. | |
| 21 | declare const process: { env: { NODE_ENV?: string } }; | |
| 22 | ||
| 16 | 23 | export interface MutationClientOptions< |
| 17 | 24 | Context extends object, |
| 18 | 25 | OptimisticHelpers extends object, |
| 26 | UserContext extends object = {}, | |
| 27 | AuthScopes extends string = never, | |
| 19 | 28 | > { |
| 20 | 29 | context: Context; |
| 21 | 30 | getOptimisticHelpers: ( |
| ... | ... | @@ -35,6 +44,30 @@ export interface MutationClientOptions< |
| 35 | 44 | * @default true |
| 36 | 45 | */ |
| 37 | 46 | enabled?: boolean; |
| 47 | /** To support authenticated mutations, define a function that returns additional context. */ | |
| 48 | userContext?: Reactive<UserContext | null>; | |
| 49 | /** | |
| 50 | * Subsets of authentication for different permission levels, used at the | |
| 51 | * define site with `auth: "<scope>"`. A scoped mutation is allowed only when | |
| 52 | * a user is available and its scope reads `true`; unlike `auth: true`, it is | |
| 53 | * never routed to `handleUnauthenticated`. | |
| 54 | */ | |
| 55 | authScopes?: Record<AuthScopes, Reactive<boolean>>; | |
| 56 | /** | |
| 57 | * Called instead of running an authenticated mutation when no user is | |
| 58 | * available. The action is JSON serializable and can be stored, then passed | |
| 59 | * to {@linkcode MutationClient.run} once the user signs in. | |
| 60 | */ | |
| 61 | handleUnauthenticated?: ( | |
| 62 | this: { context: Context }, | |
| 63 | action: MutationAction, | |
| 64 | mutation: Mutation<Json[], unknown>, | |
| 65 | ) => void; | |
| 66 | } | |
| 67 | ||
| 68 | export interface Reactive<T> { | |
| 69 | get: () => T; | |
| 70 | sub: (onChange: () => void) => () => void; | |
| 38 | 71 | } |
| 39 | 72 | |
| 40 | 73 | export interface OptimisticEvents { |
| ... | ... | @@ -45,16 +78,25 @@ export interface OptimisticEvents { |
| 45 | 78 | export class MutationClient< |
| 46 | 79 | Context extends object, |
| 47 | 80 | OptimisticHelpers extends object, |
| 81 | UserContext extends object = {}, | |
| 82 | AuthScopes extends string = never, | |
| 48 | 83 | > { |
| 49 | 84 | context: Context; |
| 85 | userContext?: Reactive<UserContext | null>; | |
| 86 | authScopes?: { [scope: string]: Reactive<boolean> }; | |
| 87 | handleUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void; | |
| 50 | 88 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; |
| 51 | 89 | reportError: (message: string, error: unknown) => void; |
| 52 | 90 | reportSuccess?: (message: string) => void; |
| 53 | 91 | deepEquals: (a: unknown, b: unknown) => boolean; |
| 54 | 92 | enabled: boolean; |
| 93 | #mutations: Map<string, Mutation<Json[], unknown>> = new Map(); | |
| 55 | 94 | |
| 56 | constructor(options: MutationClientOptions<Context, OptimisticHelpers>) { | |
| 95 | constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) { | |
| 57 | 96 | this.context = options.context; |
| 97 | this.userContext = options.userContext; | |
| 98 | this.authScopes = options.authScopes; | |
| 99 | this.handleUnauthenticated = options.handleUnauthenticated; | |
| 58 | 100 | this.getOptimisticHelpers = options.getOptimisticHelpers; |
| 59 | 101 | this.reportError = options.reportError; |
| 60 | 102 | this.reportSuccess = options.reportSuccess; |
| ... | ... | @@ -65,17 +107,49 @@ export class MutationClient< |
| 65 | 107 | /** |
| 66 | 108 | * Define a standard mutation. |
| 67 | 109 | */ |
| 68 | define<const Args extends unknown[], Result>( | |
| 110 | define<const Args extends Json[], Result, const Auth extends boolean | AuthScopes = false>( | |
| 69 | 111 | options: MutationOptions< |
| 70 | 112 | Args, |
| 71 | 113 | Result, |
| 72 | { context: Context; optimisticHelpers: OptimisticHelpers } | |
| 114 | Auth, | |
| 115 | { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } | |
| 73 | 116 | >, |
| 74 | 117 | ): Mutation<Args, Result> { |
| 75 | return new BlockingMutation< | |
| 118 | if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) { | |
| 119 | throw new Error(`Unknown auth scope "${options.auth}".`); | |
| 120 | } | |
| 121 | if (this.#mutations.has(options.id)) { | |
| 122 | // Hot reload re-evaluates defining modules against the same client, so | |
| 123 | // a duplicate is assumed to be a replacement unless this is positively | |
| 124 | // a production build. The `typeof` guard supports unbundled browsers; | |
| 125 | // bundled browser builds inline NODE_ENV but leave `typeof process` | |
| 126 | // alone, which downgrades them to the replace-and-warn path. | |
| 127 | if (typeof process !== "undefined" && process.env.NODE_ENV === "production") { | |
| 128 | throw new Error(`Mutation id "${options.id}" is already registered.`); | |
| 129 | } | |
| 130 | console.error(`Mutation id "${options.id}" registered twice; assuming hot reload and replacing it.`); | |
| 131 | } | |
| 132 | const mutation = new BlockingMutation< | |
| 76 | 133 | Args, |
| 77 | 134 | Result, |
| 78 | { context: Context; optimisticHelpers: OptimisticHelpers } | |
| 79 | >(this, options); | |
| 135 | Auth, | |
| 136 | { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } | |
| 137 | >( | |
| 138 | this, | |
| 139 | options, | |
| 140 | ); | |
| 141 | this.#mutations.set(options.id, mutation as unknown as Mutation<Json[], unknown>); | |
| 142 | return mutation; | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Re-run a mutation from a stored {@linkcode MutationAction}, such as one | |
| 147 | * captured by `handleUnauthenticated` before the user signed in. Results are | |
| 148 | * reported through the global handlers. | |
| 149 | */ | |
| 150 | run(action: Pick<MutationAction, "id" | "args">): void { | |
| 151 | const mutation = this.#mutations.get(action.id); | |
| 152 | if (!mutation) throw new Error(`Mutation id "${action.id}" is not registered.`); | |
| 153 | mutation.run(...action.args); | |
| 80 | 154 | } |
| 81 | 155 | } |
src/mod.ts+2-1| ... | ... | @@ -3,6 +3,7 @@ export { |
| 3 | 3 | type MutationClientConfig, |
| 4 | 4 | type MutationClientFromConfig, |
| 5 | 5 | type MutationClientOptions, |
| 6 | type Reactive, | |
| 6 | 7 | } from "./client.ts"; |
| 7 | 8 | export type { MutationOptions, OptimisticContext } from "./mutation.ts"; |
| 8 | 9 | export { |
| ... | ... | @@ -16,4 +17,4 @@ export { |
| 16 | 17 | type UseMutateResultBase, |
| 17 | 18 | type UseMutateSuccess, |
| 18 | 19 | } from "./react.ts"; |
| 19 | export type { Mutation, MutationEvent } from "./types.ts"; | |
| 20 | export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts"; |
src/mutation.ts+152-50| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { message as errMessage } from "@clo/lib/error.ts"; |
| 2 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | |
| 2 | import type { MutationClientFromConfig, Reactive } from "./client.ts"; | |
| 3 | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 4 | import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; | |
| 4 | import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * Argument to `defineBlocking`. |
| ... | ... | @@ -10,10 +10,26 @@ import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 10 | 10 | * @template Config - global values and helpers from `MutationContext` |
| 11 | 11 | */ |
| 12 | 12 | export interface MutationOptions< |
| 13 | Args extends unknown[], | |
| 13 | Args extends Json[], | |
| 14 | 14 | Result, |
| 15 | Auth extends boolean | string, | |
| 15 | 16 | Config extends MutationClientConfig, |
| 16 | 17 | > { |
| 18 | /** | |
| 19 | * Stable identifier, unique per client; duplicate registration throws in | |
| 20 | * production and replaces the previous registration in development, where | |
| 21 | * hot reload re-runs `define`. Identifies the mutation in a stored | |
| 22 | * `MutationAction` so a call attempted while signed out can be restored | |
| 23 | * with `MutationClient.run`. | |
| 24 | */ | |
| 25 | id: string; | |
| 26 | /** | |
| 27 | * Require a current user before the mutation can run. Passing one of the | |
| 28 | * client's `authScopes` additionally requires that scope to read `true`. | |
| 29 | */ | |
| 30 | auth?: Auth; | |
| 31 | /** Additional guard for fine-grained auth or domain-specific availability. */ | |
| 32 | isAllowed?: Reactive<boolean>; | |
| 17 | 33 | /** |
| 18 | 34 | * This function is only responsible for performing the underlying API call, |
| 19 | 35 | * syncronizing the optimistic state with reality. Throw on failure. A rest |
| ... | ... | @@ -24,27 +40,31 @@ export interface MutationOptions< |
| 24 | 40 | * In practice, optimistic context is never needed in this function, but it |
| 25 | 41 | * is provided as the `this` value if you truly desire it. |
| 26 | 42 | */ |
| 27 | mutate: (this: Config["context"], ...args: Args) => Promise<Result>; | |
| 43 | mutate: (this: Context<Config, Auth>, ...args: Args) => Promise<Result>; | |
| 44 | /** | |
| 45 | * Specifying the optimistic strategy is required. To disable, pass an empty | |
| 46 | * function with a comment to document why it isn't needed. | |
| 47 | */ | |
| 48 | optimistic: ( | |
| 49 | context: OptimisticContext<Args, Result, Config, Auth>, | |
| 50 | ) => void; | |
| 28 | 51 | /** |
| 29 | 52 | * Used in error messages and debug tools. |
| 30 | 53 | * Phrase it considering the template `Could not ${describe(...)}` |
| 54 | * | |
| 55 | * Unlike every other function, user context is nullable: this also runs | |
| 56 | * while signed out to build the `MutationAction` description given to the | |
| 57 | * sign-in flow. | |
| 31 | 58 | */ |
| 32 | describe: string | ((context: Config["context"] & { args: Args }) => string); | |
| 59 | describe: string | ((context: Context<Config, false> & { args: Args }) => string); | |
| 33 | 60 | /** |
| 34 | 61 | * Used in success messages. |
| 35 | 62 | * Phrase it as a complete success message: "Deleted Item" |
| 36 | 63 | */ |
| 37 | 64 | describeResult: |
| 38 | 65 | | string |
| 39 | | ((context: Config["context"] & { args: Args; result: Result }) => string) | |
| 66 | | ((context: Context<Config, Auth> & { args: Args; result: Result }) => string) | |
| 40 | 67 | | null; |
| 41 | /** | |
| 42 | * Specifying the optimistic strategy is required. To disable, pass an empty | |
| 43 | * function with a comment to document why it isn't needed. | |
| 44 | */ | |
| 45 | optimistic: ( | |
| 46 | context: OptimisticContext<Args, Result, Config>, | |
| 47 | ) => void; | |
| 48 | 68 | /** |
| 49 | 69 | * If the optimistic updator function is perfect, then this may be set to false. |
| 50 | 70 | * @default true |
| ... | ... | @@ -55,7 +75,7 @@ export interface MutationOptions< |
| 55 | 75 | * specifying, then all mutations of the same key will evaluate in serial, |
| 56 | 76 | * but optimistic updates will apply instantly. |
| 57 | 77 | */ |
| 58 | key?: (context: Config["context"] & { args: Args }) => string | string[]; | |
| 78 | key?: (context: Context<Config, Auth> & { args: Args }) => string | string[]; | |
| 59 | 79 | /** |
| 60 | 80 | * Enable debouncing with "last call wins" behavior. When rapid calls arrive, |
| 61 | 81 | * the previous optimistic update is rolled back and the new one applied. |
| ... | ... | @@ -76,14 +96,19 @@ export interface MutationOptions< |
| 76 | 96 | * If the snapshots are equal (using deepEquals), the mutation is cancelled. |
| 77 | 97 | * Only `onSettled` callbacks fire, not `onSuccess` or global handlers. |
| 78 | 98 | */ |
| 79 | snapshot?: (context: Config["context"] & { args: Args }) => unknown; | |
| 99 | snapshot?: (context: Context<Config, Auth> & { args: Args }) => unknown; | |
| 80 | 100 | } |
| 81 | 101 | |
| 102 | type Context<Config extends MutationClientConfig, Auth extends boolean | string> = | |
| 103 | & Config["context"] | |
| 104 | & (Auth extends false ? Partial<Config["userContext"]> : Config["userContext"]); | |
| 105 | ||
| 82 | 106 | export type OptimisticContext< |
| 83 | 107 | Args extends unknown[], |
| 84 | 108 | Result, |
| 85 | 109 | Config extends MutationClientConfig, |
| 86 | > = Config["context"] & { | |
| 110 | Auth extends boolean | string, | |
| 111 | > = Context<Config, Auth> & { | |
| 87 | 112 | args: Args; |
| 88 | 113 | helpers: Config["optimisticHelpers"]; |
| 89 | 114 | /** Add an event listener to roll back the update */ |
| ... | ... | @@ -94,9 +119,11 @@ export type OptimisticContext< |
| 94 | 119 | onRefetch: (cb: () => Promise<void>) => void; |
| 95 | 120 | }; |
| 96 | 121 | |
| 97 | interface PendingDebouncedState<Args extends unknown[], Result> { | |
| 122 | interface PendingDebouncedState<Args extends unknown[], Result, Context> { | |
| 98 | 123 | /** Arguments from the most recent call */ |
| 99 | 124 | args: Args; |
| 125 | /** Context captured when the most recent call was allowed */ | |
| 126 | context: Context; | |
| 100 | 127 | /** Number of rollbacks the most recent call added */ |
| 101 | 128 | rollbackCount: number; |
| 102 | 129 | /** All pending promises from all superseded calls */ |
| ... | ... | @@ -130,23 +157,24 @@ function unwrapMutationError(caught: unknown) { |
| 130 | 157 | return { error: caught, description: null }; |
| 131 | 158 | } |
| 132 | 159 | |
| 133 | interface Channel<Args extends unknown[], Result, OptimisticHelpers> { | |
| 160 | interface Channel<Args extends unknown[], Result, OptimisticHelpers, Context> { | |
| 134 | 161 | listeners: Set<(update: MutationEvent<Result>) => void>; |
| 135 | 162 | status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; |
| 136 | 163 | rollbacks: Array<() => void>; |
| 137 | 164 | refetches: Array<() => Promise<void>>; |
| 138 | queue: Array<Item<Args, Result>>; | |
| 165 | queue: Array<Item<Args, Result, Context>>; | |
| 139 | 166 | // Shared optimistic helpers instance for the channel |
| 140 | 167 | helpers: OptimisticHelpers | null; |
| 141 | 168 | // Debounce state (only used if debounce option is set) |
| 142 | 169 | debounceTimer: ReturnType<typeof setTimeout> | null; |
| 143 | pendingDebounced: PendingDebouncedState<Args, Result> | null; | |
| 170 | pendingDebounced: PendingDebouncedState<Args, Result, Context> | null; | |
| 144 | 171 | // Track when last debounced mutation executed (for debounceImmediate) |
| 145 | 172 | lastDebouncedExecutionTime: number | null; |
| 146 | 173 | } |
| 147 | 174 | |
| 148 | interface Item<Args extends unknown[], Result> { | |
| 175 | interface Item<Args extends unknown[], Result, Context> { | |
| 149 | 176 | args: Args; |
| 177 | context: Context; | |
| 150 | 178 | rollbacks: number; |
| 151 | 179 | onSuccess: Array<(result: Result) => void>; |
| 152 | 180 | resolve: (result: Result) => void; |
| ... | ... | @@ -154,29 +182,70 @@ interface Item<Args extends unknown[], Result> { |
| 154 | 182 | } |
| 155 | 183 | |
| 156 | 184 | export class BlockingMutation< |
| 157 | Args extends unknown[], | |
| 185 | Args extends Json[], | |
| 158 | 186 | Result, |
| 187 | Auth extends boolean | string, | |
| 159 | 188 | Config extends MutationClientConfig, |
| 160 | 189 | > implements Mutation<Args, Result> { |
| 161 | #options: MutationOptions<Args, Result, Config>; | |
| 190 | #options: MutationOptions<Args, Result, Auth, Config>; | |
| 162 | 191 | #client: MutationClientFromConfig<Config>; |
| 163 | 192 | #channels: Map< |
| 164 | 193 | string, |
| 165 | Channel<Args, Result, Config["optimisticHelpers"]> | |
| 194 | Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>> | |
| 166 | 195 | > = new Map(); |
| 167 | 196 | client: MutationClientFromConfig<Config>; |
| 168 | 197 | |
| 169 | 198 | constructor( |
| 170 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | |
| 171 | options: MutationOptions<Args, Result, Config>, | |
| 199 | client: MutationClientFromConfig<Config>, | |
| 200 | options: MutationOptions<Args, Result, Auth, Config>, | |
| 172 | 201 | ) { |
| 173 | 202 | this.#options = options; |
| 174 | 203 | this.#client = client; |
| 175 | 204 | this.client = client; |
| 176 | 205 | } |
| 177 | 206 | |
| 207 | get id(): string { | |
| 208 | return this.#options.id; | |
| 209 | } | |
| 210 | ||
| 211 | #context(): Context<Config, Auth> { | |
| 212 | return { | |
| 213 | ...this.#client.context, | |
| 214 | ...(this.#client.userContext?.get() ?? {}), | |
| 215 | } as Context<Config, Auth>; | |
| 216 | } | |
| 217 | ||
| 218 | #reportUnauthenticated(options: RunOptions<Result>, args: Args) { | |
| 219 | const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated; | |
| 220 | handler?.call( | |
| 221 | this.#client, | |
| 222 | { id: this.#options.id, description: this.describe(...args), args }, | |
| 223 | this as unknown as Mutation<Json[], unknown>, | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | isUnauthenticated(): boolean { | |
| 228 | return this.#options.auth === true && this.#client.userContext?.get() == null; | |
| 229 | } | |
| 230 | ||
| 231 | isAllowed(): boolean { | |
| 232 | const { auth } = this.#options; | |
| 233 | if (auth !== undefined && auth !== false && this.#client.userContext?.get() == null) return false; | |
| 234 | if (typeof auth === "string" && !(this.#client.authScopes?.[auth]?.get() ?? false)) return false; | |
| 235 | return this.#options.isAllowed?.get() ?? true; | |
| 236 | } | |
| 237 | ||
| 238 | subscribeAllowed(cb: () => void): () => void { | |
| 239 | const { auth } = this.#options; | |
| 240 | const unsubscribes = [ | |
| 241 | this.#options.isAllowed?.sub(cb), | |
| 242 | typeof auth === "string" ? this.#client.authScopes?.[auth]?.sub(cb) : undefined, | |
| 243 | ]; | |
| 244 | return () => unsubscribes.forEach((unsubscribe) => unsubscribe?.()); | |
| 245 | } | |
| 246 | ||
| 178 | 247 | key(args: Args) { |
| 179 | const k = this.#options.key?.({ ...this.#client.context, args }) | |
| 248 | const k = this.#options.key?.({ ...this.#context(), args }) | |
| 180 | 249 | ?? "shared"; |
| 181 | 250 | return JSON.stringify(k); |
| 182 | 251 | } |
| ... | ... | @@ -211,7 +280,7 @@ export class BlockingMutation< |
| 211 | 280 | } |
| 212 | 281 | |
| 213 | 282 | #notify( |
| 214 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 283 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 215 | 284 | status: MutationEvent<Result>["status"], |
| 216 | 285 | result: Result | null = null, |
| 217 | 286 | error: unknown = null, |
| ... | ... | @@ -222,7 +291,7 @@ export class BlockingMutation< |
| 222 | 291 | |
| 223 | 292 | #setIdle( |
| 224 | 293 | key: string, |
| 225 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 294 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 226 | 295 | ) { |
| 227 | 296 | // Check if there are pending debounced calls waiting |
| 228 | 297 | if (channel.pendingDebounced !== null) { |
| ... | ... | @@ -250,7 +319,7 @@ export class BlockingMutation< |
| 250 | 319 | describe(...args: Args): string { |
| 251 | 320 | const { describe } = this.#options; |
| 252 | 321 | return typeof describe === "function" |
| 253 | ? describe({ ...this.#client.context, args }) | |
| 322 | ? describe({ ...this.#context(), args }) | |
| 254 | 323 | : describe; |
| 255 | 324 | } |
| 256 | 325 | |
| ... | ... | @@ -258,7 +327,7 @@ export class BlockingMutation< |
| 258 | 327 | const { describeResult } = this.#options; |
| 259 | 328 | if (describeResult === null) return undefined; |
| 260 | 329 | return typeof describeResult === "function" |
| 261 | ? describeResult({ ...this.#client.context, args, result }) | |
| 330 | ? describeResult({ ...this.#context(), args, result }) | |
| 262 | 331 | : describeResult; |
| 263 | 332 | } |
| 264 | 333 | |
| ... | ... | @@ -284,8 +353,17 @@ export class BlockingMutation< |
| 284 | 353 | } |
| 285 | 354 | |
| 286 | 355 | const args = array.slice() as Args; |
| 287 | const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = args | |
| 288 | .pop() as RunOptions<Result>; | |
| 356 | const options = args.pop() as RunOptions<Result>; | |
| 357 | const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = options; | |
| 358 | ||
| 359 | if (!this.isAllowed()) { | |
| 360 | if (this.isUnauthenticated()) { | |
| 361 | this.#reportUnauthenticated(options, args); | |
| 362 | return Promise.reject(new Error("Mutation requires authentication.")); | |
| 363 | } | |
| 364 | return Promise.reject(new Error("Mutation is not allowed.")); | |
| 365 | } | |
| 366 | ||
| 289 | 367 | const promise = this.#runWithOptions(args, onRestore, true); |
| 290 | 368 | return promise.then((result) => { |
| 291 | 369 | // Call user handlers |
| ... | ... | @@ -312,8 +390,22 @@ export class BlockingMutation< |
| 312 | 390 | } |
| 313 | 391 | |
| 314 | 392 | const args = array.slice() as Args; |
| 315 | const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = args | |
| 316 | .pop() as RunOptions<Result>; | |
| 393 | const options = args.pop() as RunOptions<Result>; | |
| 394 | const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = options; | |
| 395 | if (!this.isAllowed()) { | |
| 396 | if (this.isUnauthenticated()) { | |
| 397 | this.#reportUnauthenticated(options, args); | |
| 398 | return; | |
| 399 | } | |
| 400 | const error = new Error("Mutation is not allowed."); | |
| 401 | onError?.(error); | |
| 402 | onSettled?.({ status: "error", error }); | |
| 403 | if (!onError) { | |
| 404 | this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); | |
| 405 | } | |
| 406 | return; | |
| 407 | } | |
| 408 | ||
| 317 | 409 | const suppressAll = this.#options.debounceMs !== undefined && !onSuccessUi && !onError; |
| 318 | 410 | const suppressGlobalSuccess = onSuccessUi !== undefined || suppressAll; |
| 319 | 411 | const suppressGlobalError = onError !== undefined || suppressAll; |
| ... | ... | @@ -357,6 +449,7 @@ export class BlockingMutation< |
| 357 | 449 | } |
| 358 | 450 | const key = this.key(args); |
| 359 | 451 | const channel = this.#getOrPutChannel(key); |
| 452 | const context = this.#context(); | |
| 360 | 453 | |
| 361 | 454 | // Check if debouncing is enabled |
| 362 | 455 | if (this.#options.debounceMs !== undefined) { |
| ... | ... | @@ -370,6 +463,7 @@ export class BlockingMutation< |
| 370 | 463 | args, |
| 371 | 464 | key, |
| 372 | 465 | channel, |
| 466 | context, | |
| 373 | 467 | userOnRestore, |
| 374 | 468 | !!shouldExecuteImmediate, |
| 375 | 469 | suppressGlobalHandlers, |
| ... | ... | @@ -378,7 +472,7 @@ export class BlockingMutation< |
| 378 | 472 | |
| 379 | 473 | // Take snapshot before optimistic update (if snapshot function defined) |
| 380 | 474 | const beforeSnapshot = this.#options.snapshot |
| 381 | ? this.#options.snapshot.call(this.#client.context, { args }) | |
| 475 | ? this.#options.snapshot.call(context, { ...context, args }) | |
| 382 | 476 | : undefined; |
| 383 | 477 | |
| 384 | 478 | // Create shared optimistic helpers instance for the channel if it doesn't exist |
| ... | ... | @@ -416,7 +510,7 @@ export class BlockingMutation< |
| 416 | 510 | |
| 417 | 511 | try { |
| 418 | 512 | this.#options.optimistic({ |
| 419 | ...this.#client.context, | |
| 513 | ...context, | |
| 420 | 514 | args, |
| 421 | 515 | helpers: channel.helpers, |
| 422 | 516 | onRestore, |
| ... | ... | @@ -452,8 +546,8 @@ export class BlockingMutation< |
| 452 | 546 | // Take snapshot after optimistic update and check for no-op |
| 453 | 547 | if (beforeSnapshot !== undefined) { |
| 454 | 548 | const afterSnapshot = this.#options.snapshot!.call( |
| 455 | this.#client.context, | |
| 456 | { args }, | |
| 549 | context, | |
| 550 | { ...context, args }, | |
| 457 | 551 | ); |
| 458 | 552 | const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot); |
| 459 | 553 | |
| ... | ... | @@ -475,6 +569,7 @@ export class BlockingMutation< |
| 475 | 569 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); |
| 476 | 570 | channel.queue.push({ |
| 477 | 571 | args, |
| 572 | context, | |
| 478 | 573 | rollbacks, |
| 479 | 574 | onSuccess, |
| 480 | 575 | resolve, |
| ... | ... | @@ -490,7 +585,7 @@ export class BlockingMutation< |
| 490 | 585 | |
| 491 | 586 | #executeNext( |
| 492 | 587 | key: string, |
| 493 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 588 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 494 | 589 | ) { |
| 495 | 590 | const item = channel.queue.shift(); |
| 496 | 591 | if (!item) { |
| ... | ... | @@ -498,11 +593,11 @@ export class BlockingMutation< |
| 498 | 593 | return; |
| 499 | 594 | } |
| 500 | 595 | |
| 501 | const { args, onSuccess, resolve, reject } = item; | |
| 596 | const { args, context, onSuccess, resolve, reject } = item; | |
| 502 | 597 | channel.status = "mutating"; |
| 503 | 598 | this.#notify(channel, "mutating"); |
| 504 | 599 | |
| 505 | this.#options.mutate.call(this.#client.context, ...args).then((result) => { | |
| 600 | this.#options.mutate.call(context, ...args).then((result) => { | |
| 506 | 601 | // remove rollbacks and apply optimistic success handlers |
| 507 | 602 | channel.rollbacks.splice(0, item.rollbacks); |
| 508 | 603 | onSuccess.forEach((cb) => cb(result)); |
| ... | ... | @@ -575,7 +670,8 @@ export class BlockingMutation< |
| 575 | 670 | #runDebouncedAndReturn( |
| 576 | 671 | args: Args, |
| 577 | 672 | key: string, |
| 578 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 673 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 674 | context: Context<Config, Auth>, | |
| 579 | 675 | userOnRestore: (() => void) | undefined, |
| 580 | 676 | shouldExecuteImmediate: boolean, |
| 581 | 677 | shouldCallGlobalHandler: boolean, |
| ... | ... | @@ -584,7 +680,10 @@ export class BlockingMutation< |
| 584 | 680 | const isFirstDebouncedCall = channel.pendingDebounced === null; |
| 585 | 681 | let initialSnapshot: unknown; |
| 586 | 682 | if (isFirstDebouncedCall && this.#options.snapshot) { |
| 587 | initialSnapshot = this.#options.snapshot.call(this.#client.context, { args }); | |
| 683 | initialSnapshot = this.#options.snapshot.call( | |
| 684 | context, | |
| 685 | { ...context, args }, | |
| 686 | ); | |
| 588 | 687 | } |
| 589 | 688 | |
| 590 | 689 | // If there's a pending debounced call, roll it back |
| ... | ... | @@ -627,7 +726,7 @@ export class BlockingMutation< |
| 627 | 726 | |
| 628 | 727 | try { |
| 629 | 728 | this.#options.optimistic({ |
| 630 | ...this.client.context, | |
| 729 | ...context, | |
| 631 | 730 | args, |
| 632 | 731 | helpers: channel.helpers, |
| 633 | 732 | onRestore, |
| ... | ... | @@ -664,8 +763,8 @@ export class BlockingMutation< |
| 664 | 763 | // Check for no-op by comparing to initial snapshot |
| 665 | 764 | if (this.#options.snapshot) { |
| 666 | 765 | const currentSnapshot = this.#options.snapshot.call( |
| 667 | this.#client.context, | |
| 668 | { args }, | |
| 766 | context, | |
| 767 | { ...context, args }, | |
| 669 | 768 | ); |
| 670 | 769 | const snapshotToCompare = isFirstDebouncedCall |
| 671 | 770 | ? initialSnapshot! |
| ... | ... | @@ -708,6 +807,7 @@ export class BlockingMutation< |
| 708 | 807 | // First debounced call |
| 709 | 808 | channel.pendingDebounced = { |
| 710 | 809 | args, |
| 810 | context, | |
| 711 | 811 | rollbackCount: rollbacks, |
| 712 | 812 | pending: [{ resolve, reject }], |
| 713 | 813 | onSuccess, |
| ... | ... | @@ -721,6 +821,7 @@ export class BlockingMutation< |
| 721 | 821 | } else { |
| 722 | 822 | // Subsequent debounced call - update state |
| 723 | 823 | channel.pendingDebounced.args = args; |
| 824 | channel.pendingDebounced.context = context; | |
| 724 | 825 | channel.pendingDebounced.rollbackCount = rollbacks; |
| 725 | 826 | channel.pendingDebounced.pending.push({ resolve, reject }); |
| 726 | 827 | channel.pendingDebounced.onSuccess = onSuccess; |
| ... | ... | @@ -744,7 +845,7 @@ export class BlockingMutation< |
| 744 | 845 | } |
| 745 | 846 | |
| 746 | 847 | #rollbackPendingDebounced( |
| 747 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 848 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 748 | 849 | ) { |
| 749 | 850 | if (!channel.pendingDebounced) return; |
| 750 | 851 | |
| ... | ... | @@ -763,7 +864,7 @@ export class BlockingMutation< |
| 763 | 864 | |
| 764 | 865 | #enqueueDebouncedCall( |
| 765 | 866 | key: string, |
| 766 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | |
| 867 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, | |
| 767 | 868 | ) { |
| 768 | 869 | // Clear timer |
| 769 | 870 | channel.debounceTimer = null; |
| ... | ... | @@ -774,7 +875,7 @@ export class BlockingMutation< |
| 774 | 875 | return; |
| 775 | 876 | } |
| 776 | 877 | |
| 777 | const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; | |
| 878 | const { args, context, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; | |
| 778 | 879 | channel.pendingDebounced = null; |
| 779 | 880 | |
| 780 | 881 | // Track execution time for debounceImmediate |
| ... | ... | @@ -822,6 +923,7 @@ export class BlockingMutation< |
| 822 | 923 | // Add to queue (same structure as regular blocking mutation) |
| 823 | 924 | channel.queue.push({ |
| 824 | 925 | args, |
| 926 | context, | |
| 825 | 927 | rollbacks: rollbackCount, |
| 826 | 928 | onSuccess, |
| 827 | 929 | resolve: wrapperResolve, |
src/react.ts+45-7| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { message as errMessage } from "@clo/lib/error.ts"; |
| 2 | 2 | import type { Timer } from "@clo/lib/ts.ts"; |
| 3 | import { isDisabled } from "@testing-library/user-event/dist/cjs/utils/index.js"; | |
| 4 | 3 | import { |
| 5 | 4 | type FC, |
| 6 | 5 | type MouseEvent, |
| ... | ... | @@ -30,6 +29,7 @@ export function useMutate< |
| 30 | 29 | if (mutation !== observer.mutation) { |
| 31 | 30 | observer.mutation = mutation; |
| 32 | 31 | observer.reset(); |
| 32 | observer.subscribeAllowed(); | |
| 33 | 33 | } |
| 34 | 34 | return observer.binding; |
| 35 | 35 | } |
| ... | ... | @@ -56,6 +56,8 @@ export interface UseMutateResultBase<Args extends unknown[], Result> { |
| 56 | 56 | args: Args | undefined; |
| 57 | 57 | /** `true` when controls should be disabled */ |
| 58 | 58 | isDisabled: boolean; |
| 59 | /** `true` when a mutation exists and its authentication requirement is satisfied. */ | |
| 60 | isAllowed: boolean; | |
| 59 | 61 | } |
| 60 | 62 | |
| 61 | 63 | export interface UseMutateSuccess<Result> { |
| ... | ... | @@ -121,7 +123,6 @@ type AnyMutationStateWithoutRun<Args extends unknown[], Result> = |
| 121 | 123 | isSuccess: boolean; |
| 122 | 124 | isError: boolean; |
| 123 | 125 | args: Args | undefined; |
| 124 | isDisabled: boolean; | |
| 125 | 126 | }; |
| 126 | 127 | |
| 127 | 128 | export type AnyMutationState<Args extends unknown[], Result> = |
| ... | ... | @@ -140,7 +141,6 @@ function initialState() { |
| 140 | 141 | isError: false, |
| 141 | 142 | isOptimisticData: false, |
| 142 | 143 | args: undefined, |
| 143 | isDisabled: true, | |
| 144 | 144 | } as const; |
| 145 | 145 | } |
| 146 | 146 | |
| ... | ... | @@ -148,6 +148,7 @@ class Observer<Args extends unknown[], Result> { |
| 148 | 148 | setRerender: (fn: number) => void; |
| 149 | 149 | mutation: Mutation<Args, Result> | null = null; |
| 150 | 150 | unsubscribe: (() => void) | null = null; |
| 151 | unsubscribeAllowed: (() => void) | null = null; | |
| 151 | 152 | currentKey: string | null = null; |
| 152 | 153 | pendingTimer: Timer | null = null; |
| 153 | 154 | debounced: boolean = false; |
| ... | ... | @@ -174,12 +175,30 @@ class Observer<Args extends unknown[], Result> { |
| 174 | 175 | |
| 175 | 176 | reset() { |
| 176 | 177 | this.unsubscribe?.(); |
| 178 | this.unsubscribeAllowed?.(); | |
| 177 | 179 | this.unsubscribe = null; |
| 180 | this.unsubscribeAllowed = null; | |
| 178 | 181 | this.currentKey = null; |
| 179 | 182 | this.state = initialState(); |
| 180 | 183 | this.debounced = false; |
| 181 | 184 | } |
| 182 | 185 | |
| 186 | subscribeAllowed() { | |
| 187 | const mutation = this.mutation; | |
| 188 | if (!mutation) return; | |
| 189 | const rerenderIfWatched = () => { | |
| 190 | if (this.watched.has("isAllowed") || this.watched.has("isDisabled")) { | |
| 191 | this.setRerender(Math.random()); | |
| 192 | } | |
| 193 | }; | |
| 194 | const unsubscribeMutation = mutation.subscribeAllowed(rerenderIfWatched); | |
| 195 | const unsubscribeClient = mutation.client.userContext?.sub(rerenderIfWatched) ?? (() => {}); | |
| 196 | this.unsubscribeAllowed = () => { | |
| 197 | unsubscribeMutation(); | |
| 198 | unsubscribeClient(); | |
| 199 | }; | |
| 200 | } | |
| 201 | ||
| 183 | 202 | resetPending() { |
| 184 | 203 | this.setState({ isPending: false }); |
| 185 | 204 | if (this.pendingTimer) clearTimeout(this.pendingTimer); |
| ... | ... | @@ -373,10 +392,18 @@ class Observer<Args extends unknown[], Result> { |
| 373 | 392 | self.watched.add("isMutating"); |
| 374 | 393 | return self.state.isMutating; |
| 375 | 394 | }, |
| 376 | // TODO: when auth drops this will be dependant on the auth status and isMutating | |
| 395 | get isAllowed() { | |
| 396 | self.watched.add("isAllowed"); | |
| 397 | return self.mutation?.isAllowed() ?? false; | |
| 398 | }, | |
| 377 | 399 | get isDisabled() { |
| 400 | self.watched.add("isDisabled"); | |
| 378 | 401 | self.watched.add("isMutating"); |
| 379 | return !self.mutation || (self.state.isMutating && !self.debounced); | |
| 402 | const mutation = self.mutation; | |
| 403 | if (!mutation || (self.state.isMutating && !self.debounced)) return true; | |
| 404 | if (mutation.isAllowed()) return false; | |
| 405 | // Unauthenticated clicks stay enabled when they can route to a sign-in flow. | |
| 406 | return !(mutation.isUnauthenticated() && mutation.client.handleUnauthenticated); | |
| 380 | 407 | }, |
| 381 | 408 | get isPending() { |
| 382 | 409 | self.watched.add("isPending"); |
| ... | ... | @@ -431,6 +458,8 @@ export interface MutationButtonProps<Args extends unknown[], Result> { |
| 431 | 458 | onSuccessUi?: (result: Result) => void; |
| 432 | 459 | /** Does not prevent the global handler */ |
| 433 | 460 | onSuccessData?: (result: Result) => void; |
| 461 | /** Called instead of running an authenticated mutation when no user is available. */ | |
| 462 | onUnauthenticated?: RunOptions<Result>["onUnauthenticated"]; | |
| 434 | 463 | |
| 435 | 464 | /** Global event handlers will still be called! */ |
| 436 | 465 | onSettled?: ( |
| ... | ... | @@ -442,6 +471,9 @@ export interface MutationButtonProps<Args extends unknown[], Result> { |
| 442 | 471 | error: unknown; |
| 443 | 472 | }, |
| 444 | 473 | ) => void; |
| 474 | ||
| 475 | /** Setting this to true will opt out of the behavior that non-allowed buttons are hidden. */ | |
| 476 | showNotAllowed?: boolean; | |
| 445 | 477 | } |
| 446 | 478 | |
| 447 | 479 | /** |
| ... | ... | @@ -493,7 +525,9 @@ function GenericMutationButton< |
| 493 | 525 | onError, |
| 494 | 526 | onSuccessUi, |
| 495 | 527 | onSuccessData, |
| 528 | onUnauthenticated, | |
| 496 | 529 | onSettled, |
| 530 | showNotAllowed, | |
| 497 | 531 | ...forwarded |
| 498 | 532 | } = props; |
| 499 | 533 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; |
| ... | ... | @@ -508,9 +542,13 @@ function GenericMutationButton< |
| 508 | 542 | if (!computedArgs || e.defaultPrevented) return; |
| 509 | 543 | state.runWithOptions( |
| 510 | 544 | ...computedArgs, |
| 511 | { onSuccessUi, onSuccessData, onError, onSettled }, | |
| 545 | { onSuccessUi, onSuccessData, onError, onUnauthenticated, onSettled }, | |
| 512 | 546 | ); |
| 513 | }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, state]); | |
| 547 | }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, onUnauthenticated, state]); | |
| 548 | ||
| 549 | // Buttons the user can never click are hidden; a signed-out `auth: true` | |
| 550 | // button stays visible when it can route the click to the sign-in flow. | |
| 551 | if (!showNotAllowed && !state.isAllowed && state.isDisabled) return null; | |
| 514 | 552 | |
| 515 | 553 | // NOTE: the JSR has trouble with JSX syntax for some reason. |
| 516 | 554 | return jsx( |
src/tanstack-query.ts+28-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query"; |
| 2 | import type { OptimisticEvents } from "./client.ts"; | |
| 2 | import type { OptimisticEvents, Reactive } from "./client.ts"; | |
| 3 | 3 | import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; |
| 4 | 4 | |
| 5 | 5 | export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { |
| ... | ... | @@ -19,6 +19,33 @@ export function boundQueryClientGet( |
| 19 | 19 | }; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | export function reactiveFromQueryCache<T>( | |
| 23 | client: QueryClient, | |
| 24 | { queryKey }: QueryKeyAndFn<T>, | |
| 25 | ): Reactive<T | undefined>; | |
| 26 | export function reactiveFromQueryCache<T, R>( | |
| 27 | client: QueryClient, | |
| 28 | { queryKey }: QueryKeyAndFn<T>, | |
| 29 | deriver: (value: T | undefined) => R, | |
| 30 | ): Reactive<R>; | |
| 31 | export function reactiveFromQueryCache<T>( | |
| 32 | client: QueryClient, | |
| 33 | { queryKey }: QueryKeyAndFn<T>, | |
| 34 | deriver: (value: T | undefined) => unknown = x => x, | |
| 35 | ): Reactive<unknown> { | |
| 36 | return { | |
| 37 | get: () => deriver(client.getQueryData<T>(queryKey)), | |
| 38 | sub: (onChange) => { | |
| 39 | const queryKeyJson = JSON.stringify(queryKey); | |
| 40 | return client.getQueryCache().subscribe((event) => { | |
| 41 | if (JSON.stringify(event.query.queryKey) === queryKeyJson) { | |
| 42 | onChange(); | |
| 43 | } | |
| 44 | }); | |
| 45 | }, | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 22 | 49 | class TanstackQueryOptimisticHelpers { |
| 23 | 50 | #client: QueryClient; |
| 24 | 51 | #onRefetch: OptimisticEvents["onRefetch"]; |
src/types.ts+39-3| ... | ... | @@ -1,6 +1,31 @@ |
| 1 | 1 | import type { MutationClient } from "./client.ts"; |
| 2 | 2 | |
| 3 | /** | |
| 4 | * Mutation arguments must be JSON serializable so a call can be stored and | |
| 5 | * replayed later, such as re-running a mutation after the user signs in. | |
| 6 | */ | |
| 7 | export type Json = | |
| 8 | | string | |
| 9 | | number | |
| 10 | | boolean | |
| 11 | | null | |
| 12 | | Json[] | |
| 13 | | { [key: string]: Json }; | |
| 14 | ||
| 15 | /** | |
| 16 | * A serializable record of a mutation call. Produced when an authenticated | |
| 17 | * mutation is attempted without a user; pass it to `MutationClient.run` | |
| 18 | * after sign-in to re-run the call. | |
| 19 | */ | |
| 20 | export interface MutationAction { | |
| 21 | id: string; | |
| 22 | description: string; | |
| 23 | args: Json[]; | |
| 24 | } | |
| 25 | ||
| 3 | 26 | export interface Mutation<Args extends unknown[], Result> { |
| 27 | /** Unique identifier passed to `define`. */ | |
| 28 | readonly id: string; | |
| 4 | 29 | /** Calling the mutation. Errors are turned into UI toasts. */ |
| 5 | 30 | run(...args: Args): void; |
| 6 | 31 | /** Calls the mutation with custom handlers that can suppress global handlers. */ |
| ... | ... | @@ -25,7 +50,14 @@ export interface Mutation<Args extends unknown[], Result> { |
| 25 | 50 | ): () => void; |
| 26 | 51 | describe(...args: Args): string; |
| 27 | 52 | describeResult: ((args: Args, result: Result) => string | undefined) | null; |
| 28 | client: MutationClient<object, object>; | |
| 53 | /** | |
| 54 | * `true` when an `auth: true` mutation has no user available. Scoped | |
| 55 | * mutations are excluded; they disable instead of routing to the sign-in flow. | |
| 56 | */ | |
| 57 | isUnauthenticated(): boolean; | |
| 58 | isAllowed(): boolean; | |
| 59 | subscribeAllowed(cb: () => void): () => void; | |
| 60 | client: MutationClient<object, object, object>; | |
| 29 | 61 | } |
| 30 | 62 | |
| 31 | 63 | export interface RunOptions<Result> { |
| ... | ... | @@ -47,8 +79,12 @@ export interface RunOptions<Result> { |
| 47 | 79 | ) => void; |
| 48 | 80 | /** Called when optimistic state is being restored/rolled back */ |
| 49 | 81 | onRestore?: () => void; |
| 50 | /** @internal Suppresses global handlers for debounced mutations */ | |
| 51 | __suppressGlobalForDebounce?: boolean; | |
| 82 | /** | |
| 83 | * Called instead of running an authenticated mutation when no user is | |
| 84 | * available. The action is JSON serializable and can be passed to | |
| 85 | * `MutationClient.run` after sign-in. | |
| 86 | */ | |
| 87 | onUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void; | |
| 52 | 88 | } |
| 53 | 89 | |
| 54 | 90 | export interface MutationEvent<Result> { |
test/auth.test.tsx created+568| ... | ... | @@ -0,0 +1,568 @@ |
| 1 | import { | |
| 2 | createMutationButton, | |
| 3 | type MutationAction, | |
| 4 | MutationClient, | |
| 5 | type Reactive, | |
| 6 | useMutate, | |
| 7 | } from "@clo/react-mutation"; | |
| 8 | import { assertEquals, assertThrows } from "@std/assert"; | |
| 9 | import { act, render, screen } from "@testing-library/react"; | |
| 10 | import { userEvent } from "@testing-library/user-event"; | |
| 11 | import type { FC, MouseEventHandler, ReactNode } from "react"; | |
| 12 | import { test, vi } from "vitest"; | |
| 13 | import { IterableStream } from "./share.ts"; | |
| 14 | ||
| 15 | interface User { | |
| 16 | id: string; | |
| 17 | } | |
| 18 | ||
| 19 | function mutableReactive<T>(value: T): Reactive<T> & { set(value: T): void } { | |
| 20 | const listeners = new Set<() => void>(); | |
| 21 | return { | |
| 22 | get: () => value, | |
| 23 | set(next) { | |
| 24 | value = next; | |
| 25 | listeners.forEach((listener) => listener()); | |
| 26 | }, | |
| 27 | sub(listener) { | |
| 28 | listeners.add(listener); | |
| 29 | return () => listeners.delete(listener); | |
| 30 | }, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | function createAuthClient(currentUser: () => User | null, withHandler = true, admin?: Reactive<boolean>) { | |
| 35 | const unauthenticated: MutationAction[] = []; | |
| 36 | const errors: string[] = []; | |
| 37 | const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }, "admin">({ | |
| 38 | context: { contextValue: 42 }, | |
| 39 | userContext: { | |
| 40 | get: () => { | |
| 41 | const user = currentUser(); | |
| 42 | return user ? { user, accountId: "a1" } : null; | |
| 43 | }, | |
| 44 | sub: () => () => {}, | |
| 45 | }, | |
| 46 | authScopes: admin ? { admin } : undefined, | |
| 47 | handleUnauthenticated: withHandler ? (action) => unauthenticated.push(action) : undefined, | |
| 48 | getOptimisticHelpers: () => ({}), | |
| 49 | reportError: (message) => errors.push(message), | |
| 50 | }); | |
| 51 | ||
| 52 | return { client, unauthenticated, errors }; | |
| 53 | } | |
| 54 | ||
| 55 | test("authenticated mutations receive user in every context", async () => { | |
| 56 | const currentUser = { id: "u1" }; | |
| 57 | const { client } = createAuthClient(() => currentUser); | |
| 58 | const seen: string[] = []; | |
| 59 | let snapshotValue = 0; | |
| 60 | ||
| 61 | const mutation = client.define({ | |
| 62 | id: "test-auth", | |
| 63 | auth: true, | |
| 64 | mutate: async function(value: number) { | |
| 65 | seen.push(`mutate:${this.user?.id}:${this.accountId}:${this.contextValue}:${value}`); | |
| 66 | return value + 1; | |
| 67 | }, | |
| 68 | key: ({ user, accountId, contextValue, args: [value] }) => { | |
| 69 | seen.push(`key:${user?.id}:${accountId}:${contextValue}:${value}`); | |
| 70 | return String(value); | |
| 71 | }, | |
| 72 | snapshot: ({ user, accountId, contextValue, args: [value] }) => { | |
| 73 | seen.push(`snapshot:${user?.id}:${accountId}:${contextValue}:${value}`); | |
| 74 | return snapshotValue; | |
| 75 | }, | |
| 76 | optimistic: ({ user, accountId, contextValue, args: [value] }) => { | |
| 77 | seen.push(`optimistic:${user?.id}:${accountId}:${contextValue}:${value}`); | |
| 78 | snapshotValue += 1; | |
| 79 | }, | |
| 80 | describe: ({ user, accountId, contextValue, args: [value] }) => { | |
| 81 | seen.push(`describe:${user?.id}:${accountId}:${contextValue}:${value}`); | |
| 82 | return "Test auth"; | |
| 83 | }, | |
| 84 | describeResult: ({ user, accountId, contextValue, args: [value], result }) => { | |
| 85 | seen.push(`result:${user?.id}:${accountId}:${contextValue}:${value}:${result}`); | |
| 86 | return "Tested auth"; | |
| 87 | }, | |
| 88 | refetchOnSuccess: false, | |
| 89 | }); | |
| 90 | ||
| 91 | assertEquals(mutation.key([1]), "\"1\""); | |
| 92 | assertEquals(mutation.describe(1), "Test auth"); | |
| 93 | assertEquals(mutation.describeResult?.([1], 2), "Tested auth"); | |
| 94 | assertEquals(await mutation.runAsHeadlessPromise(1, {}), 2); | |
| 95 | ||
| 96 | assertEquals(seen, [ | |
| 97 | "key:u1:a1:42:1", | |
| 98 | "describe:u1:a1:42:1", | |
| 99 | "result:u1:a1:42:1:2", | |
| 100 | "key:u1:a1:42:1", | |
| 101 | "snapshot:u1:a1:42:1", | |
| 102 | "optimistic:u1:a1:42:1", | |
| 103 | "snapshot:u1:a1:42:1", | |
| 104 | "mutate:u1:a1:42:1", | |
| 105 | ]); | |
| 106 | }); | |
| 107 | ||
| 108 | test("authenticated mutations do not run while unauthenticated", async () => { | |
| 109 | const { client, unauthenticated } = createAuthClient(() => null); | |
| 110 | const localUnauthenticated: string[] = []; | |
| 111 | const mutate = vi.fn(async () => "ok"); | |
| 112 | const optimistic = vi.fn(); | |
| 113 | ||
| 114 | const mutation = client.define({ | |
| 115 | id: "test-auth", | |
| 116 | auth: true, | |
| 117 | mutate, | |
| 118 | optimistic, | |
| 119 | describe: ({ user, contextValue }) => `Test auth as ${user?.id ?? "guest"}:${contextValue}`, | |
| 120 | describeResult: "Tested auth", | |
| 121 | }); | |
| 122 | ||
| 123 | mutation.runWithOptions({ | |
| 124 | onUnauthenticated: () => localUnauthenticated.push("local"), | |
| 125 | }); | |
| 126 | mutation.run(); | |
| 127 | await Promise.resolve(); | |
| 128 | ||
| 129 | assertEquals(localUnauthenticated, ["local"]); | |
| 130 | assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth as guest:42", args: [] }]); | |
| 131 | assertEquals(optimistic.mock.calls, []); | |
| 132 | assertEquals(mutate.mock.calls, []); | |
| 133 | }); | |
| 134 | ||
| 135 | test("duplicate mutation ids throw in production and replace in development", async () => { | |
| 136 | const { client } = createAuthClient(() => null); | |
| 137 | const options = { | |
| 138 | id: "test-duplicate", | |
| 139 | mutate: async () => "ok", | |
| 140 | optimistic: () => {}, | |
| 141 | describe: "Test duplicate", | |
| 142 | describeResult: null, | |
| 143 | refetchOnSuccess: false, | |
| 144 | }; | |
| 145 | client.define(options); | |
| 146 | ||
| 147 | vi.stubEnv("NODE_ENV", "production"); | |
| 148 | try { | |
| 149 | assertThrows(() => client.define(options), Error, "Mutation id \"test-duplicate\" is already registered."); | |
| 150 | } finally { | |
| 151 | vi.unstubAllEnvs(); | |
| 152 | } | |
| 153 | ||
| 154 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 155 | try { | |
| 156 | const replacement = vi.fn(async () => "ok"); | |
| 157 | client.define({ ...options, mutate: replacement }); | |
| 158 | assertEquals(errorSpy.mock.calls.length, 1); | |
| 159 | ||
| 160 | client.run({ id: "test-duplicate", args: [] }); | |
| 161 | assertEquals(replacement.mock.calls, [[]]); | |
| 162 | } finally { | |
| 163 | errorSpy.mockRestore(); | |
| 164 | } | |
| 165 | }); | |
| 166 | ||
| 167 | test("unauthenticated actions survive JSON storage and restore after sign-in", async () => { | |
| 168 | let currentUser: User | null = null; | |
| 169 | const { client, unauthenticated } = createAuthClient(() => currentUser); | |
| 170 | const mutate = vi.fn(async (value: number, note: string) => `${value}:${note}`); | |
| 171 | ||
| 172 | client.define({ | |
| 173 | id: "test-restore", | |
| 174 | auth: true, | |
| 175 | mutate, | |
| 176 | optimistic: () => {}, | |
| 177 | describe: "Test restore", | |
| 178 | describeResult: null, | |
| 179 | refetchOnSuccess: false, | |
| 180 | }); | |
| 181 | ||
| 182 | client.run({ id: "test-restore", args: [1, "hello"] }); | |
| 183 | assertEquals(mutate.mock.calls, []); | |
| 184 | assertEquals(unauthenticated, [{ id: "test-restore", description: "Test restore", args: [1, "hello"] }]); | |
| 185 | ||
| 186 | const stored: MutationAction = JSON.parse(JSON.stringify(unauthenticated[0])); | |
| 187 | currentUser = { id: "u1" }; | |
| 188 | client.run(stored); | |
| 189 | await Promise.resolve(); | |
| 190 | ||
| 191 | assertEquals(mutate.mock.calls, [[1, "hello"]]); | |
| 192 | assertThrows(() => client.run({ id: "test-unknown", args: [] }), Error, "not registered"); | |
| 193 | }); | |
| 194 | ||
| 195 | test("useMutate derives isAllowed and isDisabled from authentication", async () => { | |
| 196 | const user = userEvent.setup({ delay: null }); | |
| 197 | let currentUser: User | null = null; | |
| 198 | const { client } = createAuthClient(() => currentUser, false); | |
| 199 | const s = new IterableStream<string>(); | |
| 200 | ||
| 201 | const mutation = client.define({ | |
| 202 | id: "test-auth", | |
| 203 | auth: true, | |
| 204 | mutate: async () => (await s.next()).value, | |
| 205 | optimistic: () => {}, | |
| 206 | describe: "Test auth", | |
| 207 | describeResult: "Tested auth", | |
| 208 | }); | |
| 209 | ||
| 210 | let renders: Array<{ isAllowed: boolean; isDisabled: boolean; isMutating: boolean }> = []; | |
| 211 | function TestComponent() { | |
| 212 | const { run, isAllowed, isDisabled, isMutating } = useMutate(mutation); | |
| 213 | renders.push({ isAllowed, isDisabled, isMutating }); | |
| 214 | ||
| 215 | return <button data-testid="run" onClick={() => run()}>run</button>; | |
| 216 | } | |
| 217 | ||
| 218 | const view = render(<TestComponent />); | |
| 219 | assertEquals(renders, [{ isAllowed: false, isDisabled: true, isMutating: false }]); | |
| 220 | renders = []; | |
| 221 | ||
| 222 | currentUser = { id: "u1" }; | |
| 223 | view.rerender(<TestComponent />); | |
| 224 | assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]); | |
| 225 | renders = []; | |
| 226 | ||
| 227 | await act(() => user.click(screen.getByTestId("run"))); | |
| 228 | assertEquals(renders, [{ isAllowed: true, isDisabled: true, isMutating: true }]); | |
| 229 | renders = []; | |
| 230 | ||
| 231 | await act(async () => { | |
| 232 | s.push("ok"); | |
| 233 | }); | |
| 234 | assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]); | |
| 235 | }); | |
| 236 | ||
| 237 | test("useMutate reacts to userContext changes", () => { | |
| 238 | const userContext = mutableReactive<{ user: User; accountId: string } | null>(null); | |
| 239 | const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }>({ | |
| 240 | context: { contextValue: 42 }, | |
| 241 | userContext, | |
| 242 | getOptimisticHelpers: () => ({}), | |
| 243 | reportError: () => {}, | |
| 244 | }); | |
| 245 | const mutation = client.define({ | |
| 246 | id: "test-auth", | |
| 247 | auth: true, | |
| 248 | mutate: async () => "ok", | |
| 249 | optimistic: () => {}, | |
| 250 | describe: "Test auth", | |
| 251 | describeResult: "Tested auth", | |
| 252 | }); | |
| 253 | ||
| 254 | let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; | |
| 255 | function TestComponent() { | |
| 256 | const { isAllowed, isDisabled } = useMutate(mutation); | |
| 257 | renders.push({ isAllowed, isDisabled }); | |
| 258 | return null; | |
| 259 | } | |
| 260 | ||
| 261 | render(<TestComponent />); | |
| 262 | assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); | |
| 263 | renders = []; | |
| 264 | ||
| 265 | act(() => userContext.set({ user: { id: "u1" }, accountId: "a1" })); | |
| 266 | assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); | |
| 267 | }); | |
| 268 | ||
| 269 | test("useMutate reacts to mutation isAllowed changes", () => { | |
| 270 | const isAllowed = mutableReactive(false); | |
| 271 | const { client } = createAuthClient(() => null); | |
| 272 | const mutation = client.define({ | |
| 273 | id: "test-allowed", | |
| 274 | isAllowed, | |
| 275 | mutate: async () => "ok", | |
| 276 | optimistic: () => {}, | |
| 277 | describe: "Test allowed", | |
| 278 | describeResult: "Tested allowed", | |
| 279 | }); | |
| 280 | ||
| 281 | let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; | |
| 282 | function TestComponent() { | |
| 283 | const { isAllowed, isDisabled } = useMutate(mutation); | |
| 284 | renders.push({ isAllowed, isDisabled }); | |
| 285 | return null; | |
| 286 | } | |
| 287 | ||
| 288 | render(<TestComponent />); | |
| 289 | assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); | |
| 290 | renders = []; | |
| 291 | ||
| 292 | act(() => isAllowed.set(true)); | |
| 293 | assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); | |
| 294 | }); | |
| 295 | ||
| 296 | test("unauthenticated with a handler stays enabled and routes clicks to it", async () => { | |
| 297 | const user = userEvent.setup({ delay: null }); | |
| 298 | const { client, unauthenticated } = createAuthClient(() => null); | |
| 299 | const mutate = vi.fn(async () => "ok"); | |
| 300 | ||
| 301 | const mutation = client.define({ | |
| 302 | id: "test-auth", | |
| 303 | auth: true, | |
| 304 | mutate, | |
| 305 | optimistic: () => {}, | |
| 306 | describe: "Test auth", | |
| 307 | describeResult: "Tested auth", | |
| 308 | }); | |
| 309 | ||
| 310 | let lastRender = { isAllowed: true, isDisabled: true }; | |
| 311 | function TestComponent() { | |
| 312 | const { run, isAllowed, isDisabled } = useMutate(mutation); | |
| 313 | lastRender = { isAllowed, isDisabled }; | |
| 314 | return <button data-testid="run" onClick={() => run()}>run</button>; | |
| 315 | } | |
| 316 | ||
| 317 | render(<TestComponent />); | |
| 318 | assertEquals(lastRender, { isAllowed: false, isDisabled: false }); | |
| 319 | ||
| 320 | await act(() => user.click(screen.getByTestId("run"))); | |
| 321 | assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth", args: [] }]); | |
| 322 | assertEquals(mutate.mock.calls, []); | |
| 323 | }); | |
| 324 | ||
| 325 | test("running while isAllowed is false reports an error instead of the sign-in flow", () => { | |
| 326 | const isAllowed = mutableReactive(false); | |
| 327 | const { client, unauthenticated, errors } = createAuthClient(() => null); | |
| 328 | const mutate = vi.fn(async () => "ok"); | |
| 329 | ||
| 330 | const mutation = client.define({ | |
| 331 | id: "test-not-allowed", | |
| 332 | isAllowed, | |
| 333 | mutate, | |
| 334 | optimistic: () => {}, | |
| 335 | describe: "Test allowed", | |
| 336 | describeResult: null, | |
| 337 | }); | |
| 338 | ||
| 339 | mutation.run(); | |
| 340 | ||
| 341 | assertEquals(unauthenticated, []); | |
| 342 | assertEquals(mutate.mock.calls, []); | |
| 343 | assertEquals(errors, ["Could not test allowed: Mutation is not allowed."]); | |
| 344 | }); | |
| 345 | ||
| 346 | test("MutationButton hides authenticated mutations without a sign-in flow", async () => { | |
| 347 | const user = userEvent.setup({ delay: null }); | |
| 348 | const { client } = createAuthClient(() => null, false); | |
| 349 | const mutate = vi.fn(async () => "ok"); | |
| 350 | ||
| 351 | const mutation = client.define({ | |
| 352 | id: "test-auth", | |
| 353 | auth: true, | |
| 354 | mutate, | |
| 355 | optimistic: () => {}, | |
| 356 | describe: "Test auth", | |
| 357 | describeResult: "Tested auth", | |
| 358 | }); | |
| 359 | ||
| 360 | const MutationButtonBase: FC<{ | |
| 361 | children?: ReactNode; | |
| 362 | disabled?: boolean; | |
| 363 | isPending: boolean; | |
| 364 | onClick: MouseEventHandler<HTMLElement> | undefined; | |
| 365 | }> = ({ children, disabled, isPending, onClick }) => ( | |
| 366 | <button data-testid="button" disabled={disabled || isPending} onClick={onClick}> | |
| 367 | {children} | |
| 368 | </button> | |
| 369 | ); | |
| 370 | const MutationButton = createMutationButton(MutationButtonBase); | |
| 371 | ||
| 372 | const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>); | |
| 373 | assertEquals(screen.queryByTestId("button"), null); | |
| 374 | ||
| 375 | view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>); | |
| 376 | assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true); | |
| 377 | await act(() => user.click(screen.getByTestId("button"))); | |
| 378 | assertEquals(mutate.mock.calls, []); | |
| 379 | }); | |
| 380 | ||
| 381 | test("auth scopes gate mutations without routing to sign-in", () => { | |
| 382 | let currentUser: User | null = { id: "u1" }; | |
| 383 | const admin = mutableReactive(false); | |
| 384 | const { client, unauthenticated, errors } = createAuthClient(() => currentUser, true, admin); | |
| 385 | const mutate = vi.fn(async () => "ok"); | |
| 386 | ||
| 387 | const mutation = client.define({ | |
| 388 | id: "test-admin", | |
| 389 | auth: "admin", | |
| 390 | mutate, | |
| 391 | optimistic: () => {}, | |
| 392 | describe: "Test admin", | |
| 393 | describeResult: null, | |
| 394 | refetchOnSuccess: false, | |
| 395 | }); | |
| 396 | ||
| 397 | assertEquals(mutation.isAllowed(), false); | |
| 398 | assertEquals(mutation.isUnauthenticated(), false); | |
| 399 | ||
| 400 | mutation.run(); | |
| 401 | assertEquals(unauthenticated, []); | |
| 402 | assertEquals(mutate.mock.calls, []); | |
| 403 | assertEquals(errors, ["Could not test admin: Mutation is not allowed."]); | |
| 404 | ||
| 405 | admin.set(true); | |
| 406 | assertEquals(mutation.isAllowed(), true); | |
| 407 | mutation.run(); | |
| 408 | assertEquals(mutate.mock.calls, [[]]); | |
| 409 | ||
| 410 | currentUser = null; | |
| 411 | assertEquals(mutation.isAllowed(), false); | |
| 412 | assertEquals(mutation.isUnauthenticated(), false); | |
| 413 | }); | |
| 414 | ||
| 415 | test("defining an unknown auth scope throws", () => { | |
| 416 | const { client } = createAuthClient(() => null); | |
| 417 | ||
| 418 | assertThrows( | |
| 419 | () => | |
| 420 | client.define({ | |
| 421 | id: "test-unknown-scope", | |
| 422 | auth: "admin", | |
| 423 | mutate: async () => "ok", | |
| 424 | optimistic: () => {}, | |
| 425 | describe: "Test unknown scope", | |
| 426 | describeResult: null, | |
| 427 | }), | |
| 428 | Error, | |
| 429 | "Unknown auth scope \"admin\".", | |
| 430 | ); | |
| 431 | }); | |
| 432 | ||
| 433 | test("useMutate reacts to auth scope changes", () => { | |
| 434 | const admin = mutableReactive(false); | |
| 435 | const { client } = createAuthClient(() => ({ id: "u1" }), true, admin); | |
| 436 | const mutation = client.define({ | |
| 437 | id: "test-admin", | |
| 438 | auth: "admin", | |
| 439 | mutate: async () => "ok", | |
| 440 | optimistic: () => {}, | |
| 441 | describe: "Test admin", | |
| 442 | describeResult: null, | |
| 443 | }); | |
| 444 | ||
| 445 | let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; | |
| 446 | function TestComponent() { | |
| 447 | const { isAllowed, isDisabled } = useMutate(mutation); | |
| 448 | renders.push({ isAllowed, isDisabled }); | |
| 449 | return null; | |
| 450 | } | |
| 451 | ||
| 452 | render(<TestComponent />); | |
| 453 | assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); | |
| 454 | renders = []; | |
| 455 | ||
| 456 | act(() => admin.set(true)); | |
| 457 | assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); | |
| 458 | }); | |
| 459 | ||
| 460 | test("MutationButton hides non-allowed mutations unless showNotAllowed", () => { | |
| 461 | const admin = mutableReactive(false); | |
| 462 | const { client } = createAuthClient(() => ({ id: "u1" }), true, admin); | |
| 463 | const mutation = client.define({ | |
| 464 | id: "test-admin", | |
| 465 | auth: "admin", | |
| 466 | mutate: async () => "ok", | |
| 467 | optimistic: () => {}, | |
| 468 | describe: "Test admin", | |
| 469 | describeResult: null, | |
| 470 | }); | |
| 471 | ||
| 472 | const MutationButtonBase: FC<{ | |
| 473 | children?: ReactNode; | |
| 474 | disabled?: boolean; | |
| 475 | isPending: boolean; | |
| 476 | onClick: MouseEventHandler<HTMLElement> | undefined; | |
| 477 | }> = ({ children, disabled, isPending, onClick }) => ( | |
| 478 | <button data-testid="button" disabled={disabled || isPending} onClick={onClick}> | |
| 479 | {children} | |
| 480 | </button> | |
| 481 | ); | |
| 482 | const MutationButton = createMutationButton(MutationButtonBase); | |
| 483 | ||
| 484 | const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>); | |
| 485 | assertEquals(screen.queryByTestId("button"), null); | |
| 486 | ||
| 487 | view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>); | |
| 488 | assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true); | |
| 489 | ||
| 490 | act(() => admin.set(true)); | |
| 491 | assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false); | |
| 492 | }); | |
| 493 | ||
| 494 | test("MutationButton stays visible for unauthenticated mutations with a sign-in flow", () => { | |
| 495 | const { client } = createAuthClient(() => null); | |
| 496 | const mutation = client.define({ | |
| 497 | id: "test-auth", | |
| 498 | auth: true, | |
| 499 | mutate: async () => "ok", | |
| 500 | optimistic: () => {}, | |
| 501 | describe: "Test auth", | |
| 502 | describeResult: "Tested auth", | |
| 503 | }); | |
| 504 | ||
| 505 | const MutationButtonBase: FC<{ | |
| 506 | children?: ReactNode; | |
| 507 | disabled?: boolean; | |
| 508 | isPending: boolean; | |
| 509 | onClick: MouseEventHandler<HTMLElement> | undefined; | |
| 510 | }> = ({ children, disabled, isPending, onClick }) => ( | |
| 511 | <button data-testid="button" disabled={disabled || isPending} onClick={onClick}> | |
| 512 | {children} | |
| 513 | </button> | |
| 514 | ); | |
| 515 | const MutationButton = createMutationButton(MutationButtonBase); | |
| 516 | ||
| 517 | render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>); | |
| 518 | assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false); | |
| 519 | }); | |
| 520 | ||
| 521 | test("mutation isAllowed can disable unrelated to auth", () => { | |
| 522 | let allow = false; | |
| 523 | const isAllowed: Reactive<boolean> = { | |
| 524 | get: () => allow, | |
| 525 | sub: () => () => {}, | |
| 526 | }; | |
| 527 | const { client } = createAuthClient(() => null); | |
| 528 | ||
| 529 | const mutation = client.define({ | |
| 530 | id: "test-allowed", | |
| 531 | isAllowed, | |
| 532 | mutate: async () => "ok", | |
| 533 | optimistic: () => {}, | |
| 534 | describe: "Test allowed", | |
| 535 | describeResult: "Tested allowed", | |
| 536 | }); | |
| 537 | ||
| 538 | assertEquals(mutation.isAllowed(), false); | |
| 539 | allow = true; | |
| 540 | assertEquals(mutation.isAllowed(), true); | |
| 541 | }); | |
| 542 | ||
| 543 | test("authenticated mutation isAllowed receives user context", () => { | |
| 544 | let currentUser: User | null = { id: "u1" }; | |
| 545 | let allow = true; | |
| 546 | const isAllowed: Reactive<boolean> = { | |
| 547 | get: () => allow, | |
| 548 | sub: () => () => {}, | |
| 549 | }; | |
| 550 | const { client } = createAuthClient(() => currentUser); | |
| 551 | ||
| 552 | const mutation = client.define({ | |
| 553 | id: "test-auth-allowed", | |
| 554 | auth: true, | |
| 555 | isAllowed, | |
| 556 | mutate: async () => "ok", | |
| 557 | optimistic: () => {}, | |
| 558 | describe: "Test allowed", | |
| 559 | describeResult: "Tested allowed", | |
| 560 | }); | |
| 561 | ||
| 562 | assertEquals(mutation.isAllowed(), true); | |
| 563 | allow = false; | |
| 564 | assertEquals(mutation.isAllowed(), false); | |
| 565 | allow = true; | |
| 566 | currentUser = null; | |
| 567 | assertEquals(mutation.isAllowed(), false); | |
| 568 | }); |
test/debounce.test.tsx+3| ... | ... | @@ -13,6 +13,7 @@ test("debounceMs: waits before first call", async () => { |
| 13 | 13 | const s = new IterableStream<string>(); |
| 14 | 14 | |
| 15 | 15 | const mutTest = client.define({ |
| 16 | id: "test-1", | |
| 16 | 17 | mutate: async () => { |
| 17 | 18 | return (await s.next()).value; |
| 18 | 19 | }, |
| ... | ... | @@ -107,6 +108,7 @@ test("debounceMs: batch multiple calls together", async () => { |
| 107 | 108 | |
| 108 | 109 | const mutations: number[] = []; |
| 109 | 110 | const mutTest = client.define({ |
| 111 | id: "test-2", | |
| 110 | 112 | mutate: async (k: number) => { |
| 111 | 113 | mutations.push(k); |
| 112 | 114 | return (await s.next()).value; |
| ... | ... | @@ -188,6 +190,7 @@ test.todo("debounceImmediate runs the first one right away", async () => { |
| 188 | 190 | |
| 189 | 191 | const mutations: number[] = []; |
| 190 | 192 | const mutTest = client.define({ |
| 193 | id: "test-3", | |
| 191 | 194 | mutate: async (k: number) => { |
| 192 | 195 | mutations.push(k); |
| 193 | 196 | return (await s.next()).value; |
test/optimistic.test.tsx+1| ... | ... | @@ -18,6 +18,7 @@ test.each([ |
| 18 | 18 | const streamRefreshes = new IterableStream<string>(); |
| 19 | 19 | |
| 20 | 20 | const mutTest = client.define({ |
| 21 | id: "test-1", | |
| 21 | 22 | mutate: async () => { |
| 22 | 23 | return (await streamResults.next()).value; |
| 23 | 24 | }, |
test/optimistic.ts+1| ... | ... | @@ -10,6 +10,7 @@ test("passes context value to functions", async () => { |
| 10 | 10 | |
| 11 | 11 | let values: number[] = []; |
| 12 | 12 | const mutTest = client.define({ |
| 13 | id: "test-1", | |
| 13 | 14 | async mutate() { |
| 14 | 15 | values.push(this.contextValue); |
| 15 | 16 | calls.push("mutate"); |
test/ordering.test.ts+9| ... | ... | @@ -11,6 +11,7 @@ test.each([ |
| 11 | 11 | const { client, errorMessages, successMessages } = createTestMutationClient(); |
| 12 | 12 | const calls: string[] = []; |
| 13 | 13 | const mutTest = client.define({ |
| 14 | id: "test-1", | |
| 14 | 15 | mutate: async () => { |
| 15 | 16 | calls.push("mutate"); |
| 16 | 17 | await delay(100); |
| ... | ... | @@ -60,6 +61,7 @@ test("error case: describe is called before rollback", async () => { |
| 60 | 61 | let optimisticState = false; |
| 61 | 62 | |
| 62 | 63 | const mutTest = client.define({ |
| 64 | id: "test-2", | |
| 63 | 65 | mutate: async () => { |
| 64 | 66 | calls.push("mutate"); |
| 65 | 67 | await delay(100); |
| ... | ... | @@ -122,6 +124,7 @@ test("error case with refetchOnSuccess=false still refetches", async () => { |
| 122 | 124 | const calls: string[] = []; |
| 123 | 125 | |
| 124 | 126 | const mutTest = client.define({ |
| 127 | id: "test-3", | |
| 125 | 128 | mutate: async () => { |
| 126 | 129 | calls.push("mutate"); |
| 127 | 130 | await delay(100); |
| ... | ... | @@ -155,6 +158,7 @@ test("multiple mutations in sequence: ordering preserved", async () => { |
| 155 | 158 | const calls: string[] = []; |
| 156 | 159 | |
| 157 | 160 | const mutTest = client.define({ |
| 161 | id: "test-4", | |
| 158 | 162 | mutate: async (id: number) => { |
| 159 | 163 | calls.push(`mutate-${id}`); |
| 160 | 164 | await delay(100); |
| ... | ... | @@ -226,6 +230,7 @@ test("error in second mutation: first stays applied, second rolls back", async ( |
| 226 | 230 | let state = 0; |
| 227 | 231 | |
| 228 | 232 | const mutTest = client.define({ |
| 233 | id: "test-5", | |
| 229 | 234 | mutate: async (id: number) => { |
| 230 | 235 | calls.push(`mutate-${id}`); |
| 231 | 236 | await delay(100); |
| ... | ... | @@ -275,6 +280,7 @@ test("onSuccess callback ordering relative to refetch", async () => { |
| 275 | 280 | const calls: string[] = []; |
| 276 | 281 | |
| 277 | 282 | const mutTest = client.define({ |
| 283 | id: "test-6", | |
| 278 | 284 | mutate: async () => { |
| 279 | 285 | calls.push("mutate"); |
| 280 | 286 | await delay(100); |
| ... | ... | @@ -321,6 +327,7 @@ test("runWithOptions callbacks: onError called before global handler", async () |
| 321 | 327 | const calls: string[] = []; |
| 322 | 328 | |
| 323 | 329 | const mutTest = client.define({ |
| 330 | id: "test-7", | |
| 324 | 331 | mutate: async () => { |
| 325 | 332 | await delay(100); |
| 326 | 333 | throw new Error("Failed"); |
| ... | ... | @@ -364,6 +371,7 @@ test("runWithOptions callbacks: onSuccess called before global handler", async ( |
| 364 | 371 | const calls: string[] = []; |
| 365 | 372 | |
| 366 | 373 | const mutTest = client.define({ |
| 374 | id: "test-8", | |
| 367 | 375 | mutate: async () => { |
| 368 | 376 | await delay(100); |
| 369 | 377 | return "result"; |
| ... | ... | @@ -407,6 +415,7 @@ test("optimistic update with no describeResult: no success message", async () => |
| 407 | 415 | const calls: string[] = []; |
| 408 | 416 | |
| 409 | 417 | const mutTest = client.define({ |
| 418 | id: "test-9", | |
| 410 | 419 | mutate: async () => { |
| 411 | 420 | await delay(100); |
| 412 | 421 | return "result"; |
test/runWithOptions.test.tsx+2| ... | ... | @@ -13,6 +13,7 @@ test("runWithOptions should allow react hook to do local handling", async () => |
| 13 | 13 | const s = new IterableStream<string>(); |
| 14 | 14 | |
| 15 | 15 | const mutTest = client.define({ |
| 16 | id: "test-1", | |
| 16 | 17 | mutate: async () => { |
| 17 | 18 | return (await s.next()).value; |
| 18 | 19 | }, |
| ... | ... | @@ -74,6 +75,7 @@ test("runAsHeadlessPromise rejects with the underlying error", async () => { |
| 74 | 75 | const localErrors: unknown[] = []; |
| 75 | 76 | |
| 76 | 77 | const mutTest = client.define({ |
| 78 | id: "test-2", | |
| 77 | 79 | mutate: async () => { |
| 78 | 80 | return (await s.next()).value; |
| 79 | 81 | }, |
test/setError.test.tsx+3| ... | ... | @@ -12,6 +12,7 @@ test("setError should manually set error state on the hook", async () => { |
| 12 | 12 | const { client, successMessages, errorMessages } = createTestMutationClient(); |
| 13 | 13 | |
| 14 | 14 | const mutTest = client.define({ |
| 15 | id: "test-1", | |
| 15 | 16 | mutate: async () => { |
| 16 | 17 | return "success"; |
| 17 | 18 | }, |
| ... | ... | @@ -92,6 +93,7 @@ test("setError should override success state", async () => { |
| 92 | 93 | const s = new IterableStream<string>(); |
| 93 | 94 | |
| 94 | 95 | const mutTest = client.define({ |
| 96 | id: "test-2", | |
| 95 | 97 | mutate: async () => { |
| 96 | 98 | return (await s.next()).value; |
| 97 | 99 | }, |
| ... | ... | @@ -206,6 +208,7 @@ test("setError should work with different error types", async () => { |
| 206 | 208 | const { client } = createTestMutationClient(); |
| 207 | 209 | |
| 208 | 210 | const mutTest = client.define({ |
| 211 | id: "test-3", | |
| 209 | 212 | mutate: async () => { |
| 210 | 213 | return "success"; |
| 211 | 214 | }, |
test/snapshot.test.tsx+4| ... | ... | @@ -15,6 +15,7 @@ test("snapshot should skip no-op mutation", async () => { |
| 15 | 15 | let state = { value: "initial" }; |
| 16 | 16 | let failed = false; |
| 17 | 17 | const mutUpdate = client.define({ |
| 18 | id: "test-1", | |
| 18 | 19 | mutate: async (newValue: string) => { |
| 19 | 20 | failed = true; |
| 20 | 21 | }, |
| ... | ... | @@ -63,6 +64,7 @@ test("snapshot should allow mutation when value changes", async () => { |
| 63 | 64 | let state = { value: "initial" }; |
| 64 | 65 | |
| 65 | 66 | const mutUpdate = client.define({ |
| 67 | id: "test-2", | |
| 66 | 68 | mutate: async (newValue: string) => { |
| 67 | 69 | return (await s.next()).value; |
| 68 | 70 | }, |
| ... | ... | @@ -116,6 +118,7 @@ test("debounced snapshot should skip when final state equals initial", async () |
| 116 | 118 | |
| 117 | 119 | const mutations: string[] = []; |
| 118 | 120 | const mutUpdate = client.define({ |
| 121 | id: "test-3", | |
| 119 | 122 | mutate: async (newValue: string) => { |
| 120 | 123 | mutations.push(newValue); |
| 121 | 124 | }, |
| ... | ... | @@ -176,6 +179,7 @@ test("debounced snapshot should mutate when final differs from initial", async ( |
| 176 | 179 | let state = { value: "initial" }; |
| 177 | 180 | |
| 178 | 181 | const mutUpdate = client.define({ |
| 182 | id: "test-4", | |
| 179 | 183 | mutate: async (newValue: string) => { |
| 180 | 184 | return (await s.next()).value; |
| 181 | 185 | }, |
test/tanstack-query-helpers.test.ts+22-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { assertEquals } from "@std/assert"; |
| 2 | 2 | import { QueryClient, queryOptions } from "@tanstack/react-query"; |
| 3 | 3 | import { test } from "vitest"; |
| 4 | import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; | |
| 4 | import { queryClientOptimisticHelpers, reactiveFromQueryCache } from "../src/tanstack-query.ts"; | |
| 5 | 5 | |
| 6 | 6 | interface TestData { |
| 7 | 7 | name: string; |
| ... | ... | @@ -89,6 +89,27 @@ test("helpers can be spread and retain bound this", () => { |
| 89 | 89 | assertEquals(result?.count, 12); |
| 90 | 90 | }); |
| 91 | 91 | |
| 92 | test("reactiveFromQueryCache derives and subscribes to query data", () => { | |
| 93 | const { client, queryTest } = createTestQueryClient(); | |
| 94 | const reactive = reactiveFromQueryCache( | |
| 95 | client, | |
| 96 | queryTest, | |
| 97 | (data) => data ? { name: data.name } : null, | |
| 98 | ); | |
| 99 | let changes = 0; | |
| 100 | const unsubscribe = reactive.sub(() => changes += 1); | |
| 101 | ||
| 102 | assertEquals(reactive.get(), { name: "Test" }); | |
| 103 | ||
| 104 | client.setQueryData(queryTest.queryKey, { ...initialData, name: "Updated" }); | |
| 105 | assertEquals(reactive.get(), { name: "Updated" }); | |
| 106 | assertEquals(changes, 1); | |
| 107 | ||
| 108 | unsubscribe(); | |
| 109 | client.setQueryData(queryTest.queryKey, { ...initialData, name: "Ignored" }); | |
| 110 | assertEquals(changes, 1); | |
| 111 | }); | |
| 112 | ||
| 92 | 113 | // ============================================================================ |
| 93 | 114 | // set() tests |
| 94 | 115 | // ============================================================================ |
test/useMutate.test.tsx+3| ... | ... | @@ -14,6 +14,7 @@ test("useMutate - global error and success handling", async () => { |
| 14 | 14 | const s = new IterableStream<string>(); |
| 15 | 15 | |
| 16 | 16 | const mutTest = client.define({ |
| 17 | id: "test-1", | |
| 17 | 18 | mutate: async () => { |
| 18 | 19 | return (await s.next()).value; |
| 19 | 20 | }, |
| ... | ... | @@ -99,6 +100,7 @@ test("useMutate - local error and success handling", async () => { |
| 99 | 100 | const s = new IterableStream<string>(); |
| 100 | 101 | |
| 101 | 102 | const mutTest = client.define({ |
| 103 | id: "test-2", | |
| 102 | 104 | mutate: async () => { |
| 103 | 105 | return (await s.next()).value; |
| 104 | 106 | }, |
| ... | ... | @@ -282,6 +284,7 @@ test("MutationButton should allow args={null} to disable mutation runs", async ( |
| 282 | 284 | const mutate = vi.fn(async (value: number) => value + 1); |
| 283 | 285 | |
| 284 | 286 | const mutTest = client.define({ |
| 287 | id: "test-3", | |
| 285 | 288 | mutate, |
| 286 | 289 | describe: "Test the action", |
| 287 | 290 | describeResult: "Tested the action", |