authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-05-15 13:39:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-22 12:53:46-07:00
log7ad627cb1d7df5aad122280d12e72244e86cb447
treed855cb307d31a85f066bae1f003eb9dd5ceb0708
parentcc21b6dbb13e3b9425367a3e4a740ae8478a1633
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: authenticated mutations

Closes #9

20 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 Mutation1# notable changes in React Mutation
22
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.030## v2.1.0
431
5### features32### features
...@@ -14,6 +41,7 @@...@@ -14,6 +41,7 @@
14### bugfixes41### bugfixes
1542
16- typescript violation not passing client context to optimistic handlers43- 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.
1745
18## v246## v2
1947
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```ts28```ts
29import { showToastUI } from "...";29import { showToastUI } from "...";
30import { MutationClient } from "@clo/react-mutation";30import { MutationClient } from "@clo/react-mutation";
31import {31import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query";
32 boundQueryClientGet,
33 queryClientOptimisticHelpers,
34} from "@clo/react-mutation";
35import { QueryClient } from "@tanstack/react-query";32import { QueryClient } from "@tanstack/react-query";
3633
37const queryClient = new QueryClient();34const 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```
6791
...@@ -76,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... });...@@ -76,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... });
76100
77// The convention is to name handlers starting with `mut`101// The convention is to name handlers starting with `mut`
78const mutDeleteItem = mutations.define({102const mutDeleteItem = mutations.define({
103 // A stable identifier, unique per application.
104 id: "item/delete",
79 // `mutate` comes first (for type inference), and105 // `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.
217243
218```tsx244```tsx
219const mutUpdateField = mutations.define({245const 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.
252279
253```tsx280```tsx
254const mutUpdateField = mutations.define({281const mutUpdateField = mutations.define({
282 id: "item/update-field",
255 async mutate(id: string, value: string) {/* mutation */},283 async mutate(id: string, value: string) {/* mutation */},
256284
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
397Once the `MutationClient` is connected to the application's authentication
398system, 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
406while signed out to build the `action.description` given to the sign-in flow.
407
408```tsx
409const 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
422Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped
423mutation whose scope is unsatisfied always disables; it is never routed to
424`handleUnauthenticated`.
425
426```tsx
427const mutBanUser = mutations.define({
428 id: "user/ban",
429 auth: "admin",
430 async mutate(targetId: string) {/* mutation */},
431 // (...the rest...)
432});
433```
434
435By default, `MutationButton` will hide non-allowed mutations that cannot route
436to the sign-in flow, which can be opted out by passing the `showNotAllowed`
437prop.
src/client.ts+82-8
...@@ -1,21 +1,30 @@...@@ -1,21 +1,30 @@
1import { BlockingMutation, type MutationOptions } from "./mutation.ts";1import { BlockingMutation, type MutationOptions } from "./mutation.ts";
2import type { Mutation } from "./types.ts";2import type { Json, Mutation, MutationAction } from "./types.ts";
33
4export interface MutationClientConfig {4export interface MutationClientConfig {
5 context: {};5 context: {};
6 optimisticHelpers: {};6 optimisticHelpers: {};
7 userContext: {};
8 authScopes: string;
7}9}
810
9export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient<11export 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>;
1317
14const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);18const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
1519
20// Declared locally to avoid depending on node types.
21declare const process: { env: { NODE_ENV?: string } };
22
16export interface MutationClientOptions<23export 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 true44 * @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
68export interface Reactive<T> {
69 get: () => T;
70 sub: (onChange: () => void) => () => void;
38}71}
3972
40export interface OptimisticEvents {73export interface OptimisticEvents {
...@@ -45,16 +78,25 @@ export interface OptimisticEvents {...@@ -45,16 +78,25 @@ export interface OptimisticEvents {
45export class MutationClient<78export 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();
5594
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";
7export type { MutationOptions, OptimisticContext } from "./mutation.ts";8export type { MutationOptions, OptimisticContext } from "./mutation.ts";
8export {9export {
...@@ -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";
19export type { Mutation, MutationEvent } from "./types.ts";20export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts";
src/mutation.ts+152-50
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";1import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClient, MutationClientFromConfig } from "./client.ts";2import type { MutationClientFromConfig, Reactive } from "./client.ts";
3import type { MutationClientConfig } from "./client.ts";3import type { MutationClientConfig } from "./client.ts";
4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";4import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts";
55
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 */
12export interface MutationOptions<12export 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 rest35 * 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 it40 * 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 | string65 | 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 true70 * @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}
81101
102type Context<Config extends MutationClientConfig, Auth extends boolean | string> =
103 & Config["context"]
104 & (Auth extends false ? Partial<Config["userContext"]> : Config["userContext"]);
105
82export type OptimisticContext<106export 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};
96121
97interface PendingDebouncedState<Args extends unknown[], Result> {122interface 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}
132159
133interface Channel<Args extends unknown[], Result, OptimisticHelpers> {160interface 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 channel166 // 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}
147174
148interface Item<Args extends unknown[], Result> {175interface 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}
155183
156export class BlockingMutation<184export 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>;
168197
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 }
177206
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 }
212281
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<
222291
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 waiting296 // 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 }
256325
...@@ -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 }
264333
...@@ -284,8 +353,17 @@ export class BlockingMutation<...@@ -284,8 +353,17 @@ export class BlockingMutation<
284 }353 }
285354
286 const args = array.slice() as Args;355 const args = array.slice() as Args;
287 const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = args356 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 handlers369 // Call user handlers
...@@ -312,8 +390,22 @@ export class BlockingMutation<...@@ -312,8 +390,22 @@ export class BlockingMutation<
312 }390 }
313391
314 const args = array.slice() as Args;392 const args = array.slice() as Args;
315 const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = args393 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();
360453
361 // Check if debouncing is enabled454 // 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<
378472
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.snapshot474 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;
383477
384 // Create shared optimistic helpers instance for the channel if it doesn't exist478 // 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<
416510
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-op546 // 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);
459553
...@@ -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<
490585
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 }
500595
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");
504599
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 handlers601 // 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 }
589688
590 // If there's a pending debounced call, roll it back689 // If there's a pending debounced call, roll it back
...@@ -627,7 +726,7 @@ export class BlockingMutation<...@@ -627,7 +726,7 @@ export class BlockingMutation<
627726
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 snapshot763 // 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 = isFirstDebouncedCall769 const snapshotToCompare = isFirstDebouncedCall
671 ? initialSnapshot!770 ? initialSnapshot!
...@@ -708,6 +807,7 @@ export class BlockingMutation<...@@ -708,6 +807,7 @@ export class BlockingMutation<
708 // First debounced call807 // 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 state822 // 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 }
745846
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;
750851
...@@ -763,7 +864,7 @@ export class BlockingMutation<...@@ -763,7 +864,7 @@ export class BlockingMutation<
763864
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 timer869 // 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 }
776877
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;
779880
780 // Track execution time for debounceImmediate881 // 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 @@
1import { message as errMessage } from "@clo/lib/error.ts";1import { message as errMessage } from "@clo/lib/error.ts";
2import type { Timer } from "@clo/lib/ts.ts";2import type { Timer } from "@clo/lib/ts.ts";
3import { isDisabled } from "@testing-library/user-event/dist/cjs/utils/index.js";
4import {3import {
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}
6062
61export interface UseMutateSuccess<Result> {63export 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 };
126127
127export type AnyMutationState<Args extends unknown[], Result> =128export 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}
146146
...@@ -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> {
174175
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 }
182185
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 isMutating395 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"];
434463
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}
446478
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 ...forwarded531 ...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;
514552
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 @@
1import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query";1import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query";
2import type { OptimisticEvents } from "./client.ts";2import type { OptimisticEvents, Reactive } from "./client.ts";
3import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts";3import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts";
44
5export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {5export 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}
2121
22export function reactiveFromQueryCache<T>(
23 client: QueryClient,
24 { queryKey }: QueryKeyAndFn<T>,
25): Reactive<T | undefined>;
26export function reactiveFromQueryCache<T, R>(
27 client: QueryClient,
28 { queryKey }: QueryKeyAndFn<T>,
29 deriver: (value: T | undefined) => R,
30): Reactive<R>;
31export 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
22class TanstackQueryOptimisticHelpers {49class 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 @@
1import type { MutationClient } from "./client.ts";1import type { MutationClient } from "./client.ts";
22
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 */
7export 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 */
20export interface MutationAction {
21 id: string;
22 description: string;
23 args: Json[];
24}
25
3export interface Mutation<Args extends unknown[], Result> {26export 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}
3062
31export interface RunOptions<Result> {63export 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}
5389
54export interface MutationEvent<Result> {90export interface MutationEvent<Result> {
test/auth.test.tsx created+568
...@@ -0,0 +1,568 @@
1import {
2 createMutationButton,
3 type MutationAction,
4 MutationClient,
5 type Reactive,
6 useMutate,
7} from "@clo/react-mutation";
8import { assertEquals, assertThrows } from "@std/assert";
9import { act, render, screen } from "@testing-library/react";
10import { userEvent } from "@testing-library/user-event";
11import type { FC, MouseEventHandler, ReactNode } from "react";
12import { test, vi } from "vitest";
13import { IterableStream } from "./share.ts";
14
15interface User {
16 id: string;
17}
18
19function 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
34function 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
55test("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
108test("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
135test("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
167test("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
195test("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
237test("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
269test("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
296test("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
325test("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
346test("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
381test("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
415test("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
433test("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
460test("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
494test("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
521test("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
543test("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>();
1414
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 () => {
107108
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 () => {
188190
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>();
1919
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 () => {
1010
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;
6162
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[] = [];
123125
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[] = [];
156159
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;
227231
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[] = [];
276281
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[] = [];
322328
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[] = [];
365372
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[] = [];
408416
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>();
1414
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[] = [];
7576
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();
1313
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>();
9394
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();
207209
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" };
6465
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 ()
116118
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" };
177180
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 @@
1import { assertEquals } from "@std/assert";1import { assertEquals } from "@std/assert";
2import { QueryClient, queryOptions } from "@tanstack/react-query";2import { QueryClient, queryOptions } from "@tanstack/react-query";
3import { test } from "vitest";3import { test } from "vitest";
4import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";4import { queryClientOptimisticHelpers, reactiveFromQueryCache } from "../src/tanstack-query.ts";
55
6interface TestData {6interface 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});
9191
92test("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() tests114// 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>();
1515
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>();
100101
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);
283285
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",