| 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,10 +1,10 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/react-mutation", | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "2.1.0", | 3 | "version": "3.0.0", |
| 4 | "exports": { | 4 | "exports": { |
| 5 | ".": "./src/mod.ts", | 5 | ".": "./src/mod.ts", |
| 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", | 6 | "./tanstack-query": "./src/tanstack-query.ts", |
| 7 | "./object-path.ts": "./src/object-path.ts" | 7 | "./object-path": "./src/object-path.ts" |
| 8 | }, | 8 | }, |
| 9 | "imports": { | 9 | "imports": { |
| 10 | "@tanstack/react-query": "npm:@tanstack/react-query@^5", | 10 | "@tanstack/react-query": "npm:@tanstack/react-query@^5", |
| ... | @@ -16,6 +16,9 @@ | ... | @@ -16,6 +16,9 @@ |
| 16 | "README.md", | 16 | "README.md", |
| 17 | "src/**/*", | 17 | "src/**/*", |
| 18 | "test/**/*" | 18 | "test/**/*" |
| 19 | ], | ||
| 20 | "exclude": [ | ||
| 21 | "src/play.ts" | ||
| 19 | ] | 22 | ] |
| 20 | }, | 23 | }, |
| 21 | "license": "ISC" | 24 | "license": "ISC" |
package.json+5| ... | @@ -15,6 +15,11 @@ | ... | @@ -15,6 +15,11 @@ |
| 15 | "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", | 15 | "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", |
| 16 | "@std/assert": "npm:@jsr/std__assert@^1.0.17" | 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 | "devDependencies": { | 23 | "devDependencies": { |
| 19 | "@tanstack/react-query": "^5.90.20", | 24 | "@tanstack/react-query": "^5.90.20", |
| 20 | "@testing-library/react": "^16.3.2", | 25 | "@testing-library/react": "^16.3.2", |
readme.changes.md+28| ... | @@ -1,5 +1,32 @@ | ... | @@ -1,5 +1,32 @@ |
| 1 | # notable changes in React Mutation | 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 | ## v2.1.0 | 30 | ## v2.1.0 |
| 4 | 31 | ||
| 5 | ### features | 32 | ### features |
| ... | @@ -14,6 +41,7 @@ | ... | @@ -14,6 +41,7 @@ |
| 14 | ### bugfixes | 41 | ### bugfixes |
| 15 | 42 | ||
| 16 | - typescript violation not passing client context to optimistic handlers | 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 | ## v2 | 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,10 +28,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an |
| 28 | ```ts | 28 | ```ts |
| 29 | import { showToastUI } from "..."; | 29 | import { showToastUI } from "..."; |
| 30 | import { MutationClient } from "@clo/react-mutation"; | 30 | import { MutationClient } from "@clo/react-mutation"; |
| 31 | import { | 31 | import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query"; |
| 32 | boundQueryClientGet, | ||
| 33 | queryClientOptimisticHelpers, | ||
| 34 | } from "@clo/react-mutation"; | ||
| 35 | import { QueryClient } from "@tanstack/react-query"; | 32 | import { QueryClient } from "@tanstack/react-query"; |
| 36 | 33 | ||
| 37 | const queryClient = new QueryClient(); | 34 | const queryClient = new QueryClient(); |
| ... | @@ -39,7 +36,7 @@ export const mutations = new MutationClient({ | ... | @@ -39,7 +36,7 @@ export const mutations = new MutationClient({ |
| 39 | // All properties in `context` are available within every function. | 36 | // All properties in `context` are available within every function. |
| 40 | context: { | 37 | context: { |
| 41 | client: queryClient, | 38 | client: queryClient, |
| 42 | get: boundQueryClientGet(client), | 39 | get: boundQueryClientGet(queryClient), |
| 43 | 40 | ||
| 44 | // Can add any easy helpers for your codebase. | 41 | // Can add any easy helpers for your codebase. |
| 45 | navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., | 42 | navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., |
| ... | @@ -62,6 +59,33 @@ export const mutations = new MutationClient({ | ... | @@ -62,6 +59,33 @@ export const mutations = new MutationClient({ |
| 62 | reportSuccess(userFriendlySuccessMessage: string) { | 59 | reportSuccess(userFriendlySuccessMessage: string) { |
| 63 | showToastUI("success", userFriendlyErrorMessage); | 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,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... }); |
| 76 | 100 | ||
| 77 | // The convention is to name handlers starting with `mut` | 101 | // The convention is to name handlers starting with `mut` |
| 78 | const mutDeleteItem = mutations.define({ | 102 | const mutDeleteItem = mutations.define({ |
| 103 | // A stable identifier, unique per application. | ||
| 104 | id: "item/delete", | ||
| 79 | // `mutate` comes first (for type inference), and | 105 | // `mutate` comes first (for type inference), and |
| 80 | // is only worried about syncing with the backend. | 106 | // is only worried about syncing with the backend. |
| 81 | async mutate(id: string) { | 107 | async mutate(id: string) { |
| ... | @@ -217,6 +243,7 @@ will override the earlier calls by rolling back the optimistic state. | ... | @@ -217,6 +243,7 @@ will override the earlier calls by rolling back the optimistic state. |
| 217 | 243 | ||
| 218 | ```tsx | 244 | ```tsx |
| 219 | const mutUpdateField = mutations.define({ | 245 | const mutUpdateField = mutations.define({ |
| 246 | id: "item/update-field", | ||
| 220 | async mutate(id: string, value: string) { /* mutation */ }, | 247 | async mutate(id: string, value: string) { /* mutation */ }, |
| 221 | optimistic({ args: [id, value], helpers }) { | 248 | optimistic({ args: [id, value], helpers }) { |
| 222 | helpers.objSet(queryItem(id), ["value"], value); | 249 | helpers.objSet(queryItem(id), ["value"], value); |
| ... | @@ -252,6 +279,7 @@ anything, `snapshot` can be used to detect no-op mutations. | ... | @@ -252,6 +279,7 @@ anything, `snapshot` can be used to detect no-op mutations. |
| 252 | 279 | ||
| 253 | ```tsx | 280 | ```tsx |
| 254 | const mutUpdateField = mutations.define({ | 281 | const mutUpdateField = mutations.define({ |
| 282 | id: "item/update-field", | ||
| 255 | async mutate(id: string, value: string) {/* mutation */}, | 283 | async mutate(id: string, value: string) {/* mutation */}, |
| 256 | 284 | ||
| 257 | optimistic({ args: [id, value], helpers }) { | 285 | optimistic({ args: [id, value], helpers }) { |
| ... | @@ -284,6 +312,8 @@ The `useMutate(null | Mutation)` react hook returns an object with the following | ... | @@ -284,6 +312,8 @@ The `useMutate(null | Mutation)` react hook returns an object with the following |
| 284 | - `run` (Function) this starts the mutation. | 312 | - `run` (Function) this starts the mutation. |
| 285 | - `clear` (Function) clear the status of sucess or error states. | 313 | - `clear` (Function) clear the status of sucess or error states. |
| 286 | - `isPending` (boolean) if a loading indicator should be visible. | 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 | - `isSuccess` (boolean) if the mutation has succeeded. | 317 | - `isSuccess` (boolean) if the mutation has succeeded. |
| 288 | - `result` (Result or undefined) the successful result of the mutation. | 318 | - `result` (Result or undefined) the successful result of the mutation. |
| 289 | - `isError` (boolean) if the mutation failed. | 319 | - `isError` (boolean) if the mutation failed. |
| ... | @@ -361,3 +391,47 @@ It can now be used for easy mutations: | ... | @@ -361,3 +391,47 @@ It can now be used for easy mutations: |
| 361 | </MutationButton> | 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,21 +1,30 @@ |
| 1 | import { BlockingMutation, type MutationOptions } from "./mutation.ts"; | 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 | export interface MutationClientConfig { | 4 | export interface MutationClientConfig { |
| 5 | context: {}; | 5 | context: {}; |
| 6 | optimisticHelpers: {}; | 6 | optimisticHelpers: {}; |
| 7 | userContext: {}; | ||
| 8 | authScopes: string; | ||
| 7 | } | 9 | } |
| 8 | 10 | ||
| 9 | export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient< | 11 | export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient< |
| 10 | Config["context"], | 12 | Config["context"], |
| 11 | Config["optimisticHelpers"] | 13 | Config["optimisticHelpers"], |
| 14 | Config["userContext"], | ||
| 15 | Config["authScopes"] | ||
| 12 | >; | 16 | >; |
| 13 | 17 | ||
| 14 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); | 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 | export interface MutationClientOptions< | 23 | export interface MutationClientOptions< |
| 17 | Context extends object, | 24 | Context extends object, |
| 18 | OptimisticHelpers extends object, | 25 | OptimisticHelpers extends object, |
| 26 | UserContext extends object = {}, | ||
| 27 | AuthScopes extends string = never, | ||
| 19 | > { | 28 | > { |
| 20 | context: Context; | 29 | context: Context; |
| 21 | getOptimisticHelpers: ( | 30 | getOptimisticHelpers: ( |
| ... | @@ -35,6 +44,30 @@ export interface MutationClientOptions< | ... | @@ -35,6 +44,30 @@ export interface MutationClientOptions< |
| 35 | * @default true | 44 | * @default true |
| 36 | */ | 45 | */ |
| 37 | enabled?: boolean; | 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 | export interface OptimisticEvents { | 73 | export interface OptimisticEvents { |
| ... | @@ -45,16 +78,25 @@ export interface OptimisticEvents { | ... | @@ -45,16 +78,25 @@ export interface OptimisticEvents { |
| 45 | export class MutationClient< | 78 | export class MutationClient< |
| 46 | Context extends object, | 79 | Context extends object, |
| 47 | OptimisticHelpers extends object, | 80 | OptimisticHelpers extends object, |
| 81 | UserContext extends object = {}, | ||
| 82 | AuthScopes extends string = never, | ||
| 48 | > { | 83 | > { |
| 49 | context: Context; | 84 | context: Context; |
| 85 | userContext?: Reactive<UserContext | null>; | ||
| 86 | authScopes?: { [scope: string]: Reactive<boolean> }; | ||
| 87 | handleUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void; | ||
| 50 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; | 88 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; |
| 51 | reportError: (message: string, error: unknown) => void; | 89 | reportError: (message: string, error: unknown) => void; |
| 52 | reportSuccess?: (message: string) => void; | 90 | reportSuccess?: (message: string) => void; |
| 53 | deepEquals: (a: unknown, b: unknown) => boolean; | 91 | deepEquals: (a: unknown, b: unknown) => boolean; |
| 54 | enabled: boolean; | 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 | this.context = options.context; | 96 | this.context = options.context; |
| 97 | this.userContext = options.userContext; | ||
| 98 | this.authScopes = options.authScopes; | ||
| 99 | this.handleUnauthenticated = options.handleUnauthenticated; | ||
| 58 | this.getOptimisticHelpers = options.getOptimisticHelpers; | 100 | this.getOptimisticHelpers = options.getOptimisticHelpers; |
| 59 | this.reportError = options.reportError; | 101 | this.reportError = options.reportError; |
| 60 | this.reportSuccess = options.reportSuccess; | 102 | this.reportSuccess = options.reportSuccess; |
| ... | @@ -65,17 +107,49 @@ export class MutationClient< | ... | @@ -65,17 +107,49 @@ export class MutationClient< |
| 65 | /** | 107 | /** |
| 66 | * Define a standard mutation. | 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 | options: MutationOptions< | 111 | options: MutationOptions< |
| 70 | Args, | 112 | Args, |
| 71 | Result, | 113 | Result, |
| 72 | { context: Context; optimisticHelpers: OptimisticHelpers } | 114 | Auth, |
| 115 | { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } | ||
| 73 | >, | 116 | >, |
| 74 | ): Mutation<Args, Result> { | 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 | Args, | 133 | Args, |
| 77 | Result, | 134 | Result, |
| 78 | { context: Context; optimisticHelpers: OptimisticHelpers } | 135 | Auth, |
| 79 | >(this, options); | 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,6 +3,7 @@ export { |
| 3 | type MutationClientConfig, | 3 | type MutationClientConfig, |
| 4 | type MutationClientFromConfig, | 4 | type MutationClientFromConfig, |
| 5 | type MutationClientOptions, | 5 | type MutationClientOptions, |
| 6 | type Reactive, | ||
| 6 | } from "./client.ts"; | 7 | } from "./client.ts"; |
| 7 | export type { MutationOptions, OptimisticContext } from "./mutation.ts"; | 8 | export type { MutationOptions, OptimisticContext } from "./mutation.ts"; |
| 8 | export { | 9 | export { |
| ... | @@ -16,4 +17,4 @@ export { | ... | @@ -16,4 +17,4 @@ export { |
| 16 | type UseMutateResultBase, | 17 | type UseMutateResultBase, |
| 17 | type UseMutateSuccess, | 18 | type UseMutateSuccess, |
| 18 | } from "./react.ts"; | 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,7 +1,7 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | 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 | import type { MutationClientConfig } from "./client.ts"; | 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 | * Argument to `defineBlocking`. | 7 | * Argument to `defineBlocking`. |
| ... | @@ -10,10 +10,26 @@ import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; | ... | @@ -10,10 +10,26 @@ import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 10 | * @template Config - global values and helpers from `MutationContext` | 10 | * @template Config - global values and helpers from `MutationContext` |
| 11 | */ | 11 | */ |
| 12 | export interface MutationOptions< | 12 | export interface MutationOptions< |
| 13 | Args extends unknown[], | 13 | Args extends Json[], |
| 14 | Result, | 14 | Result, |
| 15 | Auth extends boolean | string, | ||
| 15 | Config extends MutationClientConfig, | 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 | * This function is only responsible for performing the underlying API call, | 34 | * This function is only responsible for performing the underlying API call, |
| 19 | * syncronizing the optimistic state with reality. Throw on failure. A rest | 35 | * syncronizing the optimistic state with reality. Throw on failure. A rest |
| ... | @@ -24,27 +40,31 @@ export interface MutationOptions< | ... | @@ -24,27 +40,31 @@ export interface MutationOptions< |
| 24 | * In practice, optimistic context is never needed in this function, but it | 40 | * In practice, optimistic context is never needed in this function, but it |
| 25 | * is provided as the `this` value if you truly desire it. | 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 | * Used in error messages and debug tools. | 52 | * Used in error messages and debug tools. |
| 30 | * Phrase it considering the template `Could not ${describe(...)}` | 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 | * Used in success messages. | 61 | * Used in success messages. |
| 35 | * Phrase it as a complete success message: "Deleted Item" | 62 | * Phrase it as a complete success message: "Deleted Item" |
| 36 | */ | 63 | */ |
| 37 | describeResult: | 64 | describeResult: |
| 38 | | string | 65 | | string |
| 39 | | ((context: Config["context"] & { args: Args; result: Result }) => string) | 66 | | ((context: Context<Config, Auth> & { args: Args; result: Result }) => string) |
| 40 | | null; | 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 | * If the optimistic updator function is perfect, then this may be set to false. | 69 | * If the optimistic updator function is perfect, then this may be set to false. |
| 50 | * @default true | 70 | * @default true |
| ... | @@ -55,7 +75,7 @@ export interface MutationOptions< | ... | @@ -55,7 +75,7 @@ export interface MutationOptions< |
| 55 | * specifying, then all mutations of the same key will evaluate in serial, | 75 | * specifying, then all mutations of the same key will evaluate in serial, |
| 56 | * but optimistic updates will apply instantly. | 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 | * Enable debouncing with "last call wins" behavior. When rapid calls arrive, | 80 | * Enable debouncing with "last call wins" behavior. When rapid calls arrive, |
| 61 | * the previous optimistic update is rolled back and the new one applied. | 81 | * the previous optimistic update is rolled back and the new one applied. |
| ... | @@ -76,14 +96,19 @@ export interface MutationOptions< | ... | @@ -76,14 +96,19 @@ export interface MutationOptions< |
| 76 | * If the snapshots are equal (using deepEquals), the mutation is cancelled. | 96 | * If the snapshots are equal (using deepEquals), the mutation is cancelled. |
| 77 | * Only `onSettled` callbacks fire, not `onSuccess` or global handlers. | 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 | export type OptimisticContext< | 106 | export type OptimisticContext< |
| 83 | Args extends unknown[], | 107 | Args extends unknown[], |
| 84 | Result, | 108 | Result, |
| 85 | Config extends MutationClientConfig, | 109 | Config extends MutationClientConfig, |
| 86 | > = Config["context"] & { | 110 | Auth extends boolean | string, |
| 111 | > = Context<Config, Auth> & { | ||
| 87 | args: Args; | 112 | args: Args; |
| 88 | helpers: Config["optimisticHelpers"]; | 113 | helpers: Config["optimisticHelpers"]; |
| 89 | /** Add an event listener to roll back the update */ | 114 | /** Add an event listener to roll back the update */ |
| ... | @@ -94,9 +119,11 @@ export type OptimisticContext< | ... | @@ -94,9 +119,11 @@ export type OptimisticContext< |
| 94 | onRefetch: (cb: () => Promise<void>) => void; | 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 | /** Arguments from the most recent call */ | 123 | /** Arguments from the most recent call */ |
| 99 | args: Args; | 124 | args: Args; |
| 125 | /** Context captured when the most recent call was allowed */ | ||
| 126 | context: Context; | ||
| 100 | /** Number of rollbacks the most recent call added */ | 127 | /** Number of rollbacks the most recent call added */ |
| 101 | rollbackCount: number; | 128 | rollbackCount: number; |
| 102 | /** All pending promises from all superseded calls */ | 129 | /** All pending promises from all superseded calls */ |
| ... | @@ -130,23 +157,24 @@ function unwrapMutationError(caught: unknown) { | ... | @@ -130,23 +157,24 @@ function unwrapMutationError(caught: unknown) { |
| 130 | return { error: caught, description: null }; | 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 | listeners: Set<(update: MutationEvent<Result>) => void>; | 161 | listeners: Set<(update: MutationEvent<Result>) => void>; |
| 135 | status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; | 162 | status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; |
| 136 | rollbacks: Array<() => void>; | 163 | rollbacks: Array<() => void>; |
| 137 | refetches: Array<() => Promise<void>>; | 164 | refetches: Array<() => Promise<void>>; |
| 138 | queue: Array<Item<Args, Result>>; | 165 | queue: Array<Item<Args, Result, Context>>; |
| 139 | // Shared optimistic helpers instance for the channel | 166 | // Shared optimistic helpers instance for the channel |
| 140 | helpers: OptimisticHelpers | null; | 167 | helpers: OptimisticHelpers | null; |
| 141 | // Debounce state (only used if debounce option is set) | 168 | // Debounce state (only used if debounce option is set) |
| 142 | debounceTimer: ReturnType<typeof setTimeout> | null; | 169 | debounceTimer: ReturnType<typeof setTimeout> | null; |
| 143 | pendingDebounced: PendingDebouncedState<Args, Result> | null; | 170 | pendingDebounced: PendingDebouncedState<Args, Result, Context> | null; |
| 144 | // Track when last debounced mutation executed (for debounceImmediate) | 171 | // Track when last debounced mutation executed (for debounceImmediate) |
| 145 | lastDebouncedExecutionTime: number | null; | 172 | lastDebouncedExecutionTime: number | null; |
| 146 | } | 173 | } |
| 147 | 174 | ||
| 148 | interface Item<Args extends unknown[], Result> { | 175 | interface Item<Args extends unknown[], Result, Context> { |
| 149 | args: Args; | 176 | args: Args; |
| 177 | context: Context; | ||
| 150 | rollbacks: number; | 178 | rollbacks: number; |
| 151 | onSuccess: Array<(result: Result) => void>; | 179 | onSuccess: Array<(result: Result) => void>; |
| 152 | resolve: (result: Result) => void; | 180 | resolve: (result: Result) => void; |
| ... | @@ -154,29 +182,70 @@ interface Item<Args extends unknown[], Result> { | ... | @@ -154,29 +182,70 @@ interface Item<Args extends unknown[], Result> { |
| 154 | } | 182 | } |
| 155 | 183 | ||
| 156 | export class BlockingMutation< | 184 | export class BlockingMutation< |
| 157 | Args extends unknown[], | 185 | Args extends Json[], |
| 158 | Result, | 186 | Result, |
| 187 | Auth extends boolean | string, | ||
| 159 | Config extends MutationClientConfig, | 188 | Config extends MutationClientConfig, |
| 160 | > implements Mutation<Args, Result> { | 189 | > implements Mutation<Args, Result> { |
| 161 | #options: MutationOptions<Args, Result, Config>; | 190 | #options: MutationOptions<Args, Result, Auth, Config>; |
| 162 | #client: MutationClientFromConfig<Config>; | 191 | #client: MutationClientFromConfig<Config>; |
| 163 | #channels: Map< | 192 | #channels: Map< |
| 164 | string, | 193 | string, |
| 165 | Channel<Args, Result, Config["optimisticHelpers"]> | 194 | Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>> |
| 166 | > = new Map(); | 195 | > = new Map(); |
| 167 | client: MutationClientFromConfig<Config>; | 196 | client: MutationClientFromConfig<Config>; |
| 168 | 197 | ||
| 169 | constructor( | 198 | constructor( |
| 170 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | 199 | client: MutationClientFromConfig<Config>, |
| 171 | options: MutationOptions<Args, Result, Config>, | 200 | options: MutationOptions<Args, Result, Auth, Config>, |
| 172 | ) { | 201 | ) { |
| 173 | this.#options = options; | 202 | this.#options = options; |
| 174 | this.#client = client; | 203 | this.#client = client; |
| 175 | this.client = client; | 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 | key(args: Args) { | 247 | key(args: Args) { |
| 179 | const k = this.#options.key?.({ ...this.#client.context, args }) | 248 | const k = this.#options.key?.({ ...this.#context(), args }) |
| 180 | ?? "shared"; | 249 | ?? "shared"; |
| 181 | return JSON.stringify(k); | 250 | return JSON.stringify(k); |
| 182 | } | 251 | } |
| ... | @@ -211,7 +280,7 @@ export class BlockingMutation< | ... | @@ -211,7 +280,7 @@ export class BlockingMutation< |
| 211 | } | 280 | } |
| 212 | 281 | ||
| 213 | #notify( | 282 | #notify( |
| 214 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | 283 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, |
| 215 | status: MutationEvent<Result>["status"], | 284 | status: MutationEvent<Result>["status"], |
| 216 | result: Result | null = null, | 285 | result: Result | null = null, |
| 217 | error: unknown = null, | 286 | error: unknown = null, |
| ... | @@ -222,7 +291,7 @@ export class BlockingMutation< | ... | @@ -222,7 +291,7 @@ export class BlockingMutation< |
| 222 | 291 | ||
| 223 | #setIdle( | 292 | #setIdle( |
| 224 | key: string, | 293 | key: string, |
| 225 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | 294 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, |
| 226 | ) { | 295 | ) { |
| 227 | // Check if there are pending debounced calls waiting | 296 | // Check if there are pending debounced calls waiting |
| 228 | if (channel.pendingDebounced !== null) { | 297 | if (channel.pendingDebounced !== null) { |
| ... | @@ -250,7 +319,7 @@ export class BlockingMutation< | ... | @@ -250,7 +319,7 @@ export class BlockingMutation< |
| 250 | describe(...args: Args): string { | 319 | describe(...args: Args): string { |
| 251 | const { describe } = this.#options; | 320 | const { describe } = this.#options; |
| 252 | return typeof describe === "function" | 321 | return typeof describe === "function" |
| 253 | ? describe({ ...this.#client.context, args }) | 322 | ? describe({ ...this.#context(), args }) |
| 254 | : describe; | 323 | : describe; |
| 255 | } | 324 | } |
| 256 | 325 | ||
| ... | @@ -258,7 +327,7 @@ export class BlockingMutation< | ... | @@ -258,7 +327,7 @@ export class BlockingMutation< |
| 258 | const { describeResult } = this.#options; | 327 | const { describeResult } = this.#options; |
| 259 | if (describeResult === null) return undefined; | 328 | if (describeResult === null) return undefined; |
| 260 | return typeof describeResult === "function" | 329 | return typeof describeResult === "function" |
| 261 | ? describeResult({ ...this.#client.context, args, result }) | 330 | ? describeResult({ ...this.#context(), args, result }) |
| 262 | : describeResult; | 331 | : describeResult; |
| 263 | } | 332 | } |
| 264 | 333 | ||
| ... | @@ -284,8 +353,17 @@ export class BlockingMutation< | ... | @@ -284,8 +353,17 @@ export class BlockingMutation< |
| 284 | } | 353 | } |
| 285 | 354 | ||
| 286 | const args = array.slice() as Args; | 355 | const args = array.slice() as Args; |
| 287 | const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = args | 356 | const options = args.pop() as RunOptions<Result>; |
| 288 | .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 | const promise = this.#runWithOptions(args, onRestore, true); | 367 | const promise = this.#runWithOptions(args, onRestore, true); |
| 290 | return promise.then((result) => { | 368 | return promise.then((result) => { |
| 291 | // Call user handlers | 369 | // Call user handlers |
| ... | @@ -312,8 +390,22 @@ export class BlockingMutation< | ... | @@ -312,8 +390,22 @@ export class BlockingMutation< |
| 312 | } | 390 | } |
| 313 | 391 | ||
| 314 | const args = array.slice() as Args; | 392 | const args = array.slice() as Args; |
| 315 | const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = args | 393 | const options = args.pop() as RunOptions<Result>; |
| 316 | .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 | const suppressAll = this.#options.debounceMs !== undefined && !onSuccessUi && !onError; | 409 | const suppressAll = this.#options.debounceMs !== undefined && !onSuccessUi && !onError; |
| 318 | const suppressGlobalSuccess = onSuccessUi !== undefined || suppressAll; | 410 | const suppressGlobalSuccess = onSuccessUi !== undefined || suppressAll; |
| 319 | const suppressGlobalError = onError !== undefined || suppressAll; | 411 | const suppressGlobalError = onError !== undefined || suppressAll; |
| ... | @@ -357,6 +449,7 @@ export class BlockingMutation< | ... | @@ -357,6 +449,7 @@ export class BlockingMutation< |
| 357 | } | 449 | } |
| 358 | const key = this.key(args); | 450 | const key = this.key(args); |
| 359 | const channel = this.#getOrPutChannel(key); | 451 | const channel = this.#getOrPutChannel(key); |
| 452 | const context = this.#context(); | ||
| 360 | 453 | ||
| 361 | // Check if debouncing is enabled | 454 | // Check if debouncing is enabled |
| 362 | if (this.#options.debounceMs !== undefined) { | 455 | if (this.#options.debounceMs !== undefined) { |
| ... | @@ -370,6 +463,7 @@ export class BlockingMutation< | ... | @@ -370,6 +463,7 @@ export class BlockingMutation< |
| 370 | args, | 463 | args, |
| 371 | key, | 464 | key, |
| 372 | channel, | 465 | channel, |
| 466 | context, | ||
| 373 | userOnRestore, | 467 | userOnRestore, |
| 374 | !!shouldExecuteImmediate, | 468 | !!shouldExecuteImmediate, |
| 375 | suppressGlobalHandlers, | 469 | suppressGlobalHandlers, |
| ... | @@ -378,7 +472,7 @@ export class BlockingMutation< | ... | @@ -378,7 +472,7 @@ export class BlockingMutation< |
| 378 | 472 | ||
| 379 | // Take snapshot before optimistic update (if snapshot function defined) | 473 | // Take snapshot before optimistic update (if snapshot function defined) |
| 380 | const beforeSnapshot = this.#options.snapshot | 474 | const beforeSnapshot = this.#options.snapshot |
| 381 | ? this.#options.snapshot.call(this.#client.context, { args }) | 475 | ? this.#options.snapshot.call(context, { ...context, args }) |
| 382 | : undefined; | 476 | : undefined; |
| 383 | 477 | ||
| 384 | // Create shared optimistic helpers instance for the channel if it doesn't exist | 478 | // Create shared optimistic helpers instance for the channel if it doesn't exist |
| ... | @@ -416,7 +510,7 @@ export class BlockingMutation< | ... | @@ -416,7 +510,7 @@ export class BlockingMutation< |
| 416 | 510 | ||
| 417 | try { | 511 | try { |
| 418 | this.#options.optimistic({ | 512 | this.#options.optimistic({ |
| 419 | ...this.#client.context, | 513 | ...context, |
| 420 | args, | 514 | args, |
| 421 | helpers: channel.helpers, | 515 | helpers: channel.helpers, |
| 422 | onRestore, | 516 | onRestore, |
| ... | @@ -452,8 +546,8 @@ export class BlockingMutation< | ... | @@ -452,8 +546,8 @@ export class BlockingMutation< |
| 452 | // Take snapshot after optimistic update and check for no-op | 546 | // Take snapshot after optimistic update and check for no-op |
| 453 | if (beforeSnapshot !== undefined) { | 547 | if (beforeSnapshot !== undefined) { |
| 454 | const afterSnapshot = this.#options.snapshot!.call( | 548 | const afterSnapshot = this.#options.snapshot!.call( |
| 455 | this.#client.context, | 549 | context, |
| 456 | { args }, | 550 | { ...context, args }, |
| 457 | ); | 551 | ); |
| 458 | const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot); | 552 | const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot); |
| 459 | 553 | ||
| ... | @@ -475,6 +569,7 @@ export class BlockingMutation< | ... | @@ -475,6 +569,7 @@ export class BlockingMutation< |
| 475 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); | 569 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); |
| 476 | channel.queue.push({ | 570 | channel.queue.push({ |
| 477 | args, | 571 | args, |
| 572 | context, | ||
| 478 | rollbacks, | 573 | rollbacks, |
| 479 | onSuccess, | 574 | onSuccess, |
| 480 | resolve, | 575 | resolve, |
| ... | @@ -490,7 +585,7 @@ export class BlockingMutation< | ... | @@ -490,7 +585,7 @@ export class BlockingMutation< |
| 490 | 585 | ||
| 491 | #executeNext( | 586 | #executeNext( |
| 492 | key: string, | 587 | key: string, |
| 493 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | 588 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, |
| 494 | ) { | 589 | ) { |
| 495 | const item = channel.queue.shift(); | 590 | const item = channel.queue.shift(); |
| 496 | if (!item) { | 591 | if (!item) { |
| ... | @@ -498,11 +593,11 @@ export class BlockingMutation< | ... | @@ -498,11 +593,11 @@ export class BlockingMutation< |
| 498 | return; | 593 | return; |
| 499 | } | 594 | } |
| 500 | 595 | ||
| 501 | const { args, onSuccess, resolve, reject } = item; | 596 | const { args, context, onSuccess, resolve, reject } = item; |
| 502 | channel.status = "mutating"; | 597 | channel.status = "mutating"; |
| 503 | this.#notify(channel, "mutating"); | 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 | // remove rollbacks and apply optimistic success handlers | 601 | // remove rollbacks and apply optimistic success handlers |
| 507 | channel.rollbacks.splice(0, item.rollbacks); | 602 | channel.rollbacks.splice(0, item.rollbacks); |
| 508 | onSuccess.forEach((cb) => cb(result)); | 603 | onSuccess.forEach((cb) => cb(result)); |
| ... | @@ -575,7 +670,8 @@ export class BlockingMutation< | ... | @@ -575,7 +670,8 @@ export class BlockingMutation< |
| 575 | #runDebouncedAndReturn( | 670 | #runDebouncedAndReturn( |
| 576 | args: Args, | 671 | args: Args, |
| 577 | key: string, | 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 | userOnRestore: (() => void) | undefined, | 675 | userOnRestore: (() => void) | undefined, |
| 580 | shouldExecuteImmediate: boolean, | 676 | shouldExecuteImmediate: boolean, |
| 581 | shouldCallGlobalHandler: boolean, | 677 | shouldCallGlobalHandler: boolean, |
| ... | @@ -584,7 +680,10 @@ export class BlockingMutation< | ... | @@ -584,7 +680,10 @@ export class BlockingMutation< |
| 584 | const isFirstDebouncedCall = channel.pendingDebounced === null; | 680 | const isFirstDebouncedCall = channel.pendingDebounced === null; |
| 585 | let initialSnapshot: unknown; | 681 | let initialSnapshot: unknown; |
| 586 | if (isFirstDebouncedCall && this.#options.snapshot) { | 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 | // If there's a pending debounced call, roll it back | 689 | // If there's a pending debounced call, roll it back |
| ... | @@ -627,7 +726,7 @@ export class BlockingMutation< | ... | @@ -627,7 +726,7 @@ export class BlockingMutation< |
| 627 | 726 | ||
| 628 | try { | 727 | try { |
| 629 | this.#options.optimistic({ | 728 | this.#options.optimistic({ |
| 630 | ...this.client.context, | 729 | ...context, |
| 631 | args, | 730 | args, |
| 632 | helpers: channel.helpers, | 731 | helpers: channel.helpers, |
| 633 | onRestore, | 732 | onRestore, |
| ... | @@ -664,8 +763,8 @@ export class BlockingMutation< | ... | @@ -664,8 +763,8 @@ export class BlockingMutation< |
| 664 | // Check for no-op by comparing to initial snapshot | 763 | // Check for no-op by comparing to initial snapshot |
| 665 | if (this.#options.snapshot) { | 764 | if (this.#options.snapshot) { |
| 666 | const currentSnapshot = this.#options.snapshot.call( | 765 | const currentSnapshot = this.#options.snapshot.call( |
| 667 | this.#client.context, | 766 | context, |
| 668 | { args }, | 767 | { ...context, args }, |
| 669 | ); | 768 | ); |
| 670 | const snapshotToCompare = isFirstDebouncedCall | 769 | const snapshotToCompare = isFirstDebouncedCall |
| 671 | ? initialSnapshot! | 770 | ? initialSnapshot! |
| ... | @@ -708,6 +807,7 @@ export class BlockingMutation< | ... | @@ -708,6 +807,7 @@ export class BlockingMutation< |
| 708 | // First debounced call | 807 | // First debounced call |
| 709 | channel.pendingDebounced = { | 808 | channel.pendingDebounced = { |
| 710 | args, | 809 | args, |
| 810 | context, | ||
| 711 | rollbackCount: rollbacks, | 811 | rollbackCount: rollbacks, |
| 712 | pending: [{ resolve, reject }], | 812 | pending: [{ resolve, reject }], |
| 713 | onSuccess, | 813 | onSuccess, |
| ... | @@ -721,6 +821,7 @@ export class BlockingMutation< | ... | @@ -721,6 +821,7 @@ export class BlockingMutation< |
| 721 | } else { | 821 | } else { |
| 722 | // Subsequent debounced call - update state | 822 | // Subsequent debounced call - update state |
| 723 | channel.pendingDebounced.args = args; | 823 | channel.pendingDebounced.args = args; |
| 824 | channel.pendingDebounced.context = context; | ||
| 724 | channel.pendingDebounced.rollbackCount = rollbacks; | 825 | channel.pendingDebounced.rollbackCount = rollbacks; |
| 725 | channel.pendingDebounced.pending.push({ resolve, reject }); | 826 | channel.pendingDebounced.pending.push({ resolve, reject }); |
| 726 | channel.pendingDebounced.onSuccess = onSuccess; | 827 | channel.pendingDebounced.onSuccess = onSuccess; |
| ... | @@ -744,7 +845,7 @@ export class BlockingMutation< | ... | @@ -744,7 +845,7 @@ export class BlockingMutation< |
| 744 | } | 845 | } |
| 745 | 846 | ||
| 746 | #rollbackPendingDebounced( | 847 | #rollbackPendingDebounced( |
| 747 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | 848 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, |
| 748 | ) { | 849 | ) { |
| 749 | if (!channel.pendingDebounced) return; | 850 | if (!channel.pendingDebounced) return; |
| 750 | 851 | ||
| ... | @@ -763,7 +864,7 @@ export class BlockingMutation< | ... | @@ -763,7 +864,7 @@ export class BlockingMutation< |
| 763 | 864 | ||
| 764 | #enqueueDebouncedCall( | 865 | #enqueueDebouncedCall( |
| 765 | key: string, | 866 | key: string, |
| 766 | channel: Channel<Args, Result, Config["optimisticHelpers"]>, | 867 | channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>, |
| 767 | ) { | 868 | ) { |
| 768 | // Clear timer | 869 | // Clear timer |
| 769 | channel.debounceTimer = null; | 870 | channel.debounceTimer = null; |
| ... | @@ -774,7 +875,7 @@ export class BlockingMutation< | ... | @@ -774,7 +875,7 @@ export class BlockingMutation< |
| 774 | return; | 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 | channel.pendingDebounced = null; | 879 | channel.pendingDebounced = null; |
| 779 | 880 | ||
| 780 | // Track execution time for debounceImmediate | 881 | // Track execution time for debounceImmediate |
| ... | @@ -822,6 +923,7 @@ export class BlockingMutation< | ... | @@ -822,6 +923,7 @@ export class BlockingMutation< |
| 822 | // Add to queue (same structure as regular blocking mutation) | 923 | // Add to queue (same structure as regular blocking mutation) |
| 823 | channel.queue.push({ | 924 | channel.queue.push({ |
| 824 | args, | 925 | args, |
| 926 | context, | ||
| 825 | rollbacks: rollbackCount, | 927 | rollbacks: rollbackCount, |
| 826 | onSuccess, | 928 | onSuccess, |
| 827 | resolve: wrapperResolve, | 929 | resolve: wrapperResolve, |
src/react.ts+45-7| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { message as errMessage } from "@clo/lib/error.ts"; | 1 | import { message as errMessage } from "@clo/lib/error.ts"; |
| 2 | import type { Timer } from "@clo/lib/ts.ts"; | 2 | import type { Timer } from "@clo/lib/ts.ts"; |
| 3 | import { isDisabled } from "@testing-library/user-event/dist/cjs/utils/index.js"; | ||
| 4 | import { | 3 | import { |
| 5 | type FC, | 4 | type FC, |
| 6 | type MouseEvent, | 5 | type MouseEvent, |
| ... | @@ -30,6 +29,7 @@ export function useMutate< | ... | @@ -30,6 +29,7 @@ export function useMutate< |
| 30 | if (mutation !== observer.mutation) { | 29 | if (mutation !== observer.mutation) { |
| 31 | observer.mutation = mutation; | 30 | observer.mutation = mutation; |
| 32 | observer.reset(); | 31 | observer.reset(); |
| 32 | observer.subscribeAllowed(); | ||
| 33 | } | 33 | } |
| 34 | return observer.binding; | 34 | return observer.binding; |
| 35 | } | 35 | } |
| ... | @@ -56,6 +56,8 @@ export interface UseMutateResultBase<Args extends unknown[], Result> { | ... | @@ -56,6 +56,8 @@ export interface UseMutateResultBase<Args extends unknown[], Result> { |
| 56 | args: Args | undefined; | 56 | args: Args | undefined; |
| 57 | /** `true` when controls should be disabled */ | 57 | /** `true` when controls should be disabled */ |
| 58 | isDisabled: boolean; | 58 | isDisabled: boolean; |
| 59 | /** `true` when a mutation exists and its authentication requirement is satisfied. */ | ||
| 60 | isAllowed: boolean; | ||
| 59 | } | 61 | } |
| 60 | 62 | ||
| 61 | export interface UseMutateSuccess<Result> { | 63 | export interface UseMutateSuccess<Result> { |
| ... | @@ -121,7 +123,6 @@ type AnyMutationStateWithoutRun<Args extends unknown[], Result> = | ... | @@ -121,7 +123,6 @@ type AnyMutationStateWithoutRun<Args extends unknown[], Result> = |
| 121 | isSuccess: boolean; | 123 | isSuccess: boolean; |
| 122 | isError: boolean; | 124 | isError: boolean; |
| 123 | args: Args | undefined; | 125 | args: Args | undefined; |
| 124 | isDisabled: boolean; | ||
| 125 | }; | 126 | }; |
| 126 | 127 | ||
| 127 | export type AnyMutationState<Args extends unknown[], Result> = | 128 | export type AnyMutationState<Args extends unknown[], Result> = |
| ... | @@ -140,7 +141,6 @@ function initialState() { | ... | @@ -140,7 +141,6 @@ function initialState() { |
| 140 | isError: false, | 141 | isError: false, |
| 141 | isOptimisticData: false, | 142 | isOptimisticData: false, |
| 142 | args: undefined, | 143 | args: undefined, |
| 143 | isDisabled: true, | ||
| 144 | } as const; | 144 | } as const; |
| 145 | } | 145 | } |
| 146 | 146 | ||
| ... | @@ -148,6 +148,7 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -148,6 +148,7 @@ class Observer<Args extends unknown[], Result> { |
| 148 | setRerender: (fn: number) => void; | 148 | setRerender: (fn: number) => void; |
| 149 | mutation: Mutation<Args, Result> | null = null; | 149 | mutation: Mutation<Args, Result> | null = null; |
| 150 | unsubscribe: (() => void) | null = null; | 150 | unsubscribe: (() => void) | null = null; |
| 151 | unsubscribeAllowed: (() => void) | null = null; | ||
| 151 | currentKey: string | null = null; | 152 | currentKey: string | null = null; |
| 152 | pendingTimer: Timer | null = null; | 153 | pendingTimer: Timer | null = null; |
| 153 | debounced: boolean = false; | 154 | debounced: boolean = false; |
| ... | @@ -174,12 +175,30 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -174,12 +175,30 @@ class Observer<Args extends unknown[], Result> { |
| 174 | 175 | ||
| 175 | reset() { | 176 | reset() { |
| 176 | this.unsubscribe?.(); | 177 | this.unsubscribe?.(); |
| 178 | this.unsubscribeAllowed?.(); | ||
| 177 | this.unsubscribe = null; | 179 | this.unsubscribe = null; |
| 180 | this.unsubscribeAllowed = null; | ||
| 178 | this.currentKey = null; | 181 | this.currentKey = null; |
| 179 | this.state = initialState(); | 182 | this.state = initialState(); |
| 180 | this.debounced = false; | 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 | resetPending() { | 202 | resetPending() { |
| 184 | this.setState({ isPending: false }); | 203 | this.setState({ isPending: false }); |
| 185 | if (this.pendingTimer) clearTimeout(this.pendingTimer); | 204 | if (this.pendingTimer) clearTimeout(this.pendingTimer); |
| ... | @@ -373,10 +392,18 @@ class Observer<Args extends unknown[], Result> { | ... | @@ -373,10 +392,18 @@ class Observer<Args extends unknown[], Result> { |
| 373 | self.watched.add("isMutating"); | 392 | self.watched.add("isMutating"); |
| 374 | return self.state.isMutating; | 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 | get isDisabled() { | 399 | get isDisabled() { |
| 400 | self.watched.add("isDisabled"); | ||
| 378 | self.watched.add("isMutating"); | 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 | get isPending() { | 408 | get isPending() { |
| 382 | self.watched.add("isPending"); | 409 | self.watched.add("isPending"); |
| ... | @@ -431,6 +458,8 @@ export interface MutationButtonProps<Args extends unknown[], Result> { | ... | @@ -431,6 +458,8 @@ export interface MutationButtonProps<Args extends unknown[], Result> { |
| 431 | onSuccessUi?: (result: Result) => void; | 458 | onSuccessUi?: (result: Result) => void; |
| 432 | /** Does not prevent the global handler */ | 459 | /** Does not prevent the global handler */ |
| 433 | onSuccessData?: (result: Result) => void; | 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 | /** Global event handlers will still be called! */ | 464 | /** Global event handlers will still be called! */ |
| 436 | onSettled?: ( | 465 | onSettled?: ( |
| ... | @@ -442,6 +471,9 @@ export interface MutationButtonProps<Args extends unknown[], Result> { | ... | @@ -442,6 +471,9 @@ export interface MutationButtonProps<Args extends unknown[], Result> { |
| 442 | error: unknown; | 471 | error: unknown; |
| 443 | }, | 472 | }, |
| 444 | ) => void; | 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,7 +525,9 @@ function GenericMutationButton< |
| 493 | onError, | 525 | onError, |
| 494 | onSuccessUi, | 526 | onSuccessUi, |
| 495 | onSuccessData, | 527 | onSuccessData, |
| 528 | onUnauthenticated, | ||
| 496 | onSettled, | 529 | onSettled, |
| 530 | showNotAllowed, | ||
| 497 | ...forwarded | 531 | ...forwarded |
| 498 | } = props; | 532 | } = props; |
| 499 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; | 533 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; |
| ... | @@ -508,9 +542,13 @@ function GenericMutationButton< | ... | @@ -508,9 +542,13 @@ function GenericMutationButton< |
| 508 | if (!computedArgs || e.defaultPrevented) return; | 542 | if (!computedArgs || e.defaultPrevented) return; |
| 509 | state.runWithOptions( | 543 | state.runWithOptions( |
| 510 | ...computedArgs, | 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 | // NOTE: the JSR has trouble with JSX syntax for some reason. | 553 | // NOTE: the JSR has trouble with JSX syntax for some reason. |
| 516 | return jsx( | 554 | return jsx( |
src/tanstack-query.ts+28-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query"; | 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 | import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; | 3 | import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; |
| 4 | 4 | ||
| 5 | export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { | 5 | export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = { |
| ... | @@ -19,6 +19,33 @@ export function boundQueryClientGet( | ... | @@ -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 | class TanstackQueryOptimisticHelpers { | 49 | class TanstackQueryOptimisticHelpers { |
| 23 | #client: QueryClient; | 50 | #client: QueryClient; |
| 24 | #onRefetch: OptimisticEvents["onRefetch"]; | 51 | #onRefetch: OptimisticEvents["onRefetch"]; |
src/types.ts+39-3| ... | @@ -1,6 +1,31 @@ | ... | @@ -1,6 +1,31 @@ |
| 1 | import type { MutationClient } from "./client.ts"; | 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 | export interface Mutation<Args extends unknown[], Result> { | 26 | export interface Mutation<Args extends unknown[], Result> { |
| 27 | /** Unique identifier passed to `define`. */ | ||
| 28 | readonly id: string; | ||
| 4 | /** Calling the mutation. Errors are turned into UI toasts. */ | 29 | /** Calling the mutation. Errors are turned into UI toasts. */ |
| 5 | run(...args: Args): void; | 30 | run(...args: Args): void; |
| 6 | /** Calls the mutation with custom handlers that can suppress global handlers. */ | 31 | /** Calls the mutation with custom handlers that can suppress global handlers. */ |
| ... | @@ -25,7 +50,14 @@ export interface Mutation<Args extends unknown[], Result> { | ... | @@ -25,7 +50,14 @@ export interface Mutation<Args extends unknown[], Result> { |
| 25 | ): () => void; | 50 | ): () => void; |
| 26 | describe(...args: Args): string; | 51 | describe(...args: Args): string; |
| 27 | describeResult: ((args: Args, result: Result) => string | undefined) | null; | 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 | export interface RunOptions<Result> { | 63 | export interface RunOptions<Result> { |
| ... | @@ -47,8 +79,12 @@ export interface RunOptions<Result> { | ... | @@ -47,8 +79,12 @@ export interface RunOptions<Result> { |
| 47 | ) => void; | 79 | ) => void; |
| 48 | /** Called when optimistic state is being restored/rolled back */ | 80 | /** Called when optimistic state is being restored/rolled back */ |
| 49 | onRestore?: () => void; | 81 | onRestore?: () => void; |
| 50 | /** @internal Suppresses global handlers for debounced mutations */ | 82 | /** |
| 51 | __suppressGlobalForDebounce?: boolean; | 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 | export interface MutationEvent<Result> { | 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,6 +13,7 @@ test("debounceMs: waits before first call", async () => { |
| 13 | const s = new IterableStream<string>(); | 13 | const s = new IterableStream<string>(); |
| 14 | 14 | ||
| 15 | const mutTest = client.define({ | 15 | const mutTest = client.define({ |
| 16 | id: "test-1", | ||
| 16 | mutate: async () => { | 17 | mutate: async () => { |
| 17 | return (await s.next()).value; | 18 | return (await s.next()).value; |
| 18 | }, | 19 | }, |
| ... | @@ -107,6 +108,7 @@ test("debounceMs: batch multiple calls together", async () => { | ... | @@ -107,6 +108,7 @@ test("debounceMs: batch multiple calls together", async () => { |
| 107 | 108 | ||
| 108 | const mutations: number[] = []; | 109 | const mutations: number[] = []; |
| 109 | const mutTest = client.define({ | 110 | const mutTest = client.define({ |
| 111 | id: "test-2", | ||
| 110 | mutate: async (k: number) => { | 112 | mutate: async (k: number) => { |
| 111 | mutations.push(k); | 113 | mutations.push(k); |
| 112 | return (await s.next()).value; | 114 | return (await s.next()).value; |
| ... | @@ -188,6 +190,7 @@ test.todo("debounceImmediate runs the first one right away", async () => { | ... | @@ -188,6 +190,7 @@ test.todo("debounceImmediate runs the first one right away", async () => { |
| 188 | 190 | ||
| 189 | const mutations: number[] = []; | 191 | const mutations: number[] = []; |
| 190 | const mutTest = client.define({ | 192 | const mutTest = client.define({ |
| 193 | id: "test-3", | ||
| 191 | mutate: async (k: number) => { | 194 | mutate: async (k: number) => { |
| 192 | mutations.push(k); | 195 | mutations.push(k); |
| 193 | return (await s.next()).value; | 196 | return (await s.next()).value; |
test/optimistic.test.tsx+1| ... | @@ -18,6 +18,7 @@ test.each([ | ... | @@ -18,6 +18,7 @@ test.each([ |
| 18 | const streamRefreshes = new IterableStream<string>(); | 18 | const streamRefreshes = new IterableStream<string>(); |
| 19 | 19 | ||
| 20 | const mutTest = client.define({ | 20 | const mutTest = client.define({ |
| 21 | id: "test-1", | ||
| 21 | mutate: async () => { | 22 | mutate: async () => { |
| 22 | return (await streamResults.next()).value; | 23 | return (await streamResults.next()).value; |
| 23 | }, | 24 | }, |
test/optimistic.ts+1| ... | @@ -10,6 +10,7 @@ test("passes context value to functions", async () => { | ... | @@ -10,6 +10,7 @@ test("passes context value to functions", async () => { |
| 10 | 10 | ||
| 11 | let values: number[] = []; | 11 | let values: number[] = []; |
| 12 | const mutTest = client.define({ | 12 | const mutTest = client.define({ |
| 13 | id: "test-1", | ||
| 13 | async mutate() { | 14 | async mutate() { |
| 14 | values.push(this.contextValue); | 15 | values.push(this.contextValue); |
| 15 | calls.push("mutate"); | 16 | calls.push("mutate"); |
test/ordering.test.ts+9| ... | @@ -11,6 +11,7 @@ test.each([ | ... | @@ -11,6 +11,7 @@ test.each([ |
| 11 | const { client, errorMessages, successMessages } = createTestMutationClient(); | 11 | const { client, errorMessages, successMessages } = createTestMutationClient(); |
| 12 | const calls: string[] = []; | 12 | const calls: string[] = []; |
| 13 | const mutTest = client.define({ | 13 | const mutTest = client.define({ |
| 14 | id: "test-1", | ||
| 14 | mutate: async () => { | 15 | mutate: async () => { |
| 15 | calls.push("mutate"); | 16 | calls.push("mutate"); |
| 16 | await delay(100); | 17 | await delay(100); |
| ... | @@ -60,6 +61,7 @@ test("error case: describe is called before rollback", async () => { | ... | @@ -60,6 +61,7 @@ test("error case: describe is called before rollback", async () => { |
| 60 | let optimisticState = false; | 61 | let optimisticState = false; |
| 61 | 62 | ||
| 62 | const mutTest = client.define({ | 63 | const mutTest = client.define({ |
| 64 | id: "test-2", | ||
| 63 | mutate: async () => { | 65 | mutate: async () => { |
| 64 | calls.push("mutate"); | 66 | calls.push("mutate"); |
| 65 | await delay(100); | 67 | await delay(100); |
| ... | @@ -122,6 +124,7 @@ test("error case with refetchOnSuccess=false still refetches", async () => { | ... | @@ -122,6 +124,7 @@ test("error case with refetchOnSuccess=false still refetches", async () => { |
| 122 | const calls: string[] = []; | 124 | const calls: string[] = []; |
| 123 | 125 | ||
| 124 | const mutTest = client.define({ | 126 | const mutTest = client.define({ |
| 127 | id: "test-3", | ||
| 125 | mutate: async () => { | 128 | mutate: async () => { |
| 126 | calls.push("mutate"); | 129 | calls.push("mutate"); |
| 127 | await delay(100); | 130 | await delay(100); |
| ... | @@ -155,6 +158,7 @@ test("multiple mutations in sequence: ordering preserved", async () => { | ... | @@ -155,6 +158,7 @@ test("multiple mutations in sequence: ordering preserved", async () => { |
| 155 | const calls: string[] = []; | 158 | const calls: string[] = []; |
| 156 | 159 | ||
| 157 | const mutTest = client.define({ | 160 | const mutTest = client.define({ |
| 161 | id: "test-4", | ||
| 158 | mutate: async (id: number) => { | 162 | mutate: async (id: number) => { |
| 159 | calls.push(`mutate-${id}`); | 163 | calls.push(`mutate-${id}`); |
| 160 | await delay(100); | 164 | await delay(100); |
| ... | @@ -226,6 +230,7 @@ test("error in second mutation: first stays applied, second rolls back", async ( | ... | @@ -226,6 +230,7 @@ test("error in second mutation: first stays applied, second rolls back", async ( |
| 226 | let state = 0; | 230 | let state = 0; |
| 227 | 231 | ||
| 228 | const mutTest = client.define({ | 232 | const mutTest = client.define({ |
| 233 | id: "test-5", | ||
| 229 | mutate: async (id: number) => { | 234 | mutate: async (id: number) => { |
| 230 | calls.push(`mutate-${id}`); | 235 | calls.push(`mutate-${id}`); |
| 231 | await delay(100); | 236 | await delay(100); |
| ... | @@ -275,6 +280,7 @@ test("onSuccess callback ordering relative to refetch", async () => { | ... | @@ -275,6 +280,7 @@ test("onSuccess callback ordering relative to refetch", async () => { |
| 275 | const calls: string[] = []; | 280 | const calls: string[] = []; |
| 276 | 281 | ||
| 277 | const mutTest = client.define({ | 282 | const mutTest = client.define({ |
| 283 | id: "test-6", | ||
| 278 | mutate: async () => { | 284 | mutate: async () => { |
| 279 | calls.push("mutate"); | 285 | calls.push("mutate"); |
| 280 | await delay(100); | 286 | await delay(100); |
| ... | @@ -321,6 +327,7 @@ test("runWithOptions callbacks: onError called before global handler", async () | ... | @@ -321,6 +327,7 @@ test("runWithOptions callbacks: onError called before global handler", async () |
| 321 | const calls: string[] = []; | 327 | const calls: string[] = []; |
| 322 | 328 | ||
| 323 | const mutTest = client.define({ | 329 | const mutTest = client.define({ |
| 330 | id: "test-7", | ||
| 324 | mutate: async () => { | 331 | mutate: async () => { |
| 325 | await delay(100); | 332 | await delay(100); |
| 326 | throw new Error("Failed"); | 333 | throw new Error("Failed"); |
| ... | @@ -364,6 +371,7 @@ test("runWithOptions callbacks: onSuccess called before global handler", async ( | ... | @@ -364,6 +371,7 @@ test("runWithOptions callbacks: onSuccess called before global handler", async ( |
| 364 | const calls: string[] = []; | 371 | const calls: string[] = []; |
| 365 | 372 | ||
| 366 | const mutTest = client.define({ | 373 | const mutTest = client.define({ |
| 374 | id: "test-8", | ||
| 367 | mutate: async () => { | 375 | mutate: async () => { |
| 368 | await delay(100); | 376 | await delay(100); |
| 369 | return "result"; | 377 | return "result"; |
| ... | @@ -407,6 +415,7 @@ test("optimistic update with no describeResult: no success message", async () => | ... | @@ -407,6 +415,7 @@ test("optimistic update with no describeResult: no success message", async () => |
| 407 | const calls: string[] = []; | 415 | const calls: string[] = []; |
| 408 | 416 | ||
| 409 | const mutTest = client.define({ | 417 | const mutTest = client.define({ |
| 418 | id: "test-9", | ||
| 410 | mutate: async () => { | 419 | mutate: async () => { |
| 411 | await delay(100); | 420 | await delay(100); |
| 412 | return "result"; | 421 | return "result"; |
test/runWithOptions.test.tsx+2| ... | @@ -13,6 +13,7 @@ test("runWithOptions should allow react hook to do local handling", async () => | ... | @@ -13,6 +13,7 @@ test("runWithOptions should allow react hook to do local handling", async () => |
| 13 | const s = new IterableStream<string>(); | 13 | const s = new IterableStream<string>(); |
| 14 | 14 | ||
| 15 | const mutTest = client.define({ | 15 | const mutTest = client.define({ |
| 16 | id: "test-1", | ||
| 16 | mutate: async () => { | 17 | mutate: async () => { |
| 17 | return (await s.next()).value; | 18 | return (await s.next()).value; |
| 18 | }, | 19 | }, |
| ... | @@ -74,6 +75,7 @@ test("runAsHeadlessPromise rejects with the underlying error", async () => { | ... | @@ -74,6 +75,7 @@ test("runAsHeadlessPromise rejects with the underlying error", async () => { |
| 74 | const localErrors: unknown[] = []; | 75 | const localErrors: unknown[] = []; |
| 75 | 76 | ||
| 76 | const mutTest = client.define({ | 77 | const mutTest = client.define({ |
| 78 | id: "test-2", | ||
| 77 | mutate: async () => { | 79 | mutate: async () => { |
| 78 | return (await s.next()).value; | 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,6 +12,7 @@ test("setError should manually set error state on the hook", async () => { |
| 12 | const { client, successMessages, errorMessages } = createTestMutationClient(); | 12 | const { client, successMessages, errorMessages } = createTestMutationClient(); |
| 13 | 13 | ||
| 14 | const mutTest = client.define({ | 14 | const mutTest = client.define({ |
| 15 | id: "test-1", | ||
| 15 | mutate: async () => { | 16 | mutate: async () => { |
| 16 | return "success"; | 17 | return "success"; |
| 17 | }, | 18 | }, |
| ... | @@ -92,6 +93,7 @@ test("setError should override success state", async () => { | ... | @@ -92,6 +93,7 @@ test("setError should override success state", async () => { |
| 92 | const s = new IterableStream<string>(); | 93 | const s = new IterableStream<string>(); |
| 93 | 94 | ||
| 94 | const mutTest = client.define({ | 95 | const mutTest = client.define({ |
| 96 | id: "test-2", | ||
| 95 | mutate: async () => { | 97 | mutate: async () => { |
| 96 | return (await s.next()).value; | 98 | return (await s.next()).value; |
| 97 | }, | 99 | }, |
| ... | @@ -206,6 +208,7 @@ test("setError should work with different error types", async () => { | ... | @@ -206,6 +208,7 @@ test("setError should work with different error types", async () => { |
| 206 | const { client } = createTestMutationClient(); | 208 | const { client } = createTestMutationClient(); |
| 207 | 209 | ||
| 208 | const mutTest = client.define({ | 210 | const mutTest = client.define({ |
| 211 | id: "test-3", | ||
| 209 | mutate: async () => { | 212 | mutate: async () => { |
| 210 | return "success"; | 213 | return "success"; |
| 211 | }, | 214 | }, |
test/snapshot.test.tsx+4| ... | @@ -15,6 +15,7 @@ test("snapshot should skip no-op mutation", async () => { | ... | @@ -15,6 +15,7 @@ test("snapshot should skip no-op mutation", async () => { |
| 15 | let state = { value: "initial" }; | 15 | let state = { value: "initial" }; |
| 16 | let failed = false; | 16 | let failed = false; |
| 17 | const mutUpdate = client.define({ | 17 | const mutUpdate = client.define({ |
| 18 | id: "test-1", | ||
| 18 | mutate: async (newValue: string) => { | 19 | mutate: async (newValue: string) => { |
| 19 | failed = true; | 20 | failed = true; |
| 20 | }, | 21 | }, |
| ... | @@ -63,6 +64,7 @@ test("snapshot should allow mutation when value changes", async () => { | ... | @@ -63,6 +64,7 @@ test("snapshot should allow mutation when value changes", async () => { |
| 63 | let state = { value: "initial" }; | 64 | let state = { value: "initial" }; |
| 64 | 65 | ||
| 65 | const mutUpdate = client.define({ | 66 | const mutUpdate = client.define({ |
| 67 | id: "test-2", | ||
| 66 | mutate: async (newValue: string) => { | 68 | mutate: async (newValue: string) => { |
| 67 | return (await s.next()).value; | 69 | return (await s.next()).value; |
| 68 | }, | 70 | }, |
| ... | @@ -116,6 +118,7 @@ test("debounced snapshot should skip when final state equals initial", async () | ... | @@ -116,6 +118,7 @@ test("debounced snapshot should skip when final state equals initial", async () |
| 116 | 118 | ||
| 117 | const mutations: string[] = []; | 119 | const mutations: string[] = []; |
| 118 | const mutUpdate = client.define({ | 120 | const mutUpdate = client.define({ |
| 121 | id: "test-3", | ||
| 119 | mutate: async (newValue: string) => { | 122 | mutate: async (newValue: string) => { |
| 120 | mutations.push(newValue); | 123 | mutations.push(newValue); |
| 121 | }, | 124 | }, |
| ... | @@ -176,6 +179,7 @@ test("debounced snapshot should mutate when final differs from initial", async ( | ... | @@ -176,6 +179,7 @@ test("debounced snapshot should mutate when final differs from initial", async ( |
| 176 | let state = { value: "initial" }; | 179 | let state = { value: "initial" }; |
| 177 | 180 | ||
| 178 | const mutUpdate = client.define({ | 181 | const mutUpdate = client.define({ |
| 182 | id: "test-4", | ||
| 179 | mutate: async (newValue: string) => { | 183 | mutate: async (newValue: string) => { |
| 180 | return (await s.next()).value; | 184 | return (await s.next()).value; |
| 181 | }, | 185 | }, |
test/tanstack-query-helpers.test.ts+22-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { assertEquals } from "@std/assert"; | 1 | import { assertEquals } from "@std/assert"; |
| 2 | import { QueryClient, queryOptions } from "@tanstack/react-query"; | 2 | import { QueryClient, queryOptions } from "@tanstack/react-query"; |
| 3 | import { test } from "vitest"; | 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 | interface TestData { | 6 | interface TestData { |
| 7 | name: string; | 7 | name: string; |
| ... | @@ -89,6 +89,27 @@ test("helpers can be spread and retain bound this", () => { | ... | @@ -89,6 +89,27 @@ test("helpers can be spread and retain bound this", () => { |
| 89 | assertEquals(result?.count, 12); | 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 | // set() tests | 114 | // set() tests |
| 94 | // ============================================================================ | 115 | // ============================================================================ |
test/useMutate.test.tsx+3| ... | @@ -14,6 +14,7 @@ test("useMutate - global error and success handling", async () => { | ... | @@ -14,6 +14,7 @@ test("useMutate - global error and success handling", async () => { |
| 14 | const s = new IterableStream<string>(); | 14 | const s = new IterableStream<string>(); |
| 15 | 15 | ||
| 16 | const mutTest = client.define({ | 16 | const mutTest = client.define({ |
| 17 | id: "test-1", | ||
| 17 | mutate: async () => { | 18 | mutate: async () => { |
| 18 | return (await s.next()).value; | 19 | return (await s.next()).value; |
| 19 | }, | 20 | }, |
| ... | @@ -99,6 +100,7 @@ test("useMutate - local error and success handling", async () => { | ... | @@ -99,6 +100,7 @@ test("useMutate - local error and success handling", async () => { |
| 99 | const s = new IterableStream<string>(); | 100 | const s = new IterableStream<string>(); |
| 100 | 101 | ||
| 101 | const mutTest = client.define({ | 102 | const mutTest = client.define({ |
| 103 | id: "test-2", | ||
| 102 | mutate: async () => { | 104 | mutate: async () => { |
| 103 | return (await s.next()).value; | 105 | return (await s.next()).value; |
| 104 | }, | 106 | }, |
| ... | @@ -282,6 +284,7 @@ test("MutationButton should allow args={null} to disable mutation runs", async ( | ... | @@ -282,6 +284,7 @@ test("MutationButton should allow args={null} to disable mutation runs", async ( |
| 282 | const mutate = vi.fn(async (value: number) => value + 1); | 284 | const mutate = vi.fn(async (value: number) => value + 1); |
| 283 | 285 | ||
| 284 | const mutTest = client.define({ | 286 | const mutTest = client.define({ |
| 287 | id: "test-3", | ||
| 285 | mutate, | 288 | mutate, |
| 286 | describe: "Test the action", | 289 | describe: "Test the action", |
| 287 | describeResult: "Tested the action", | 290 | describeResult: "Tested the action", |