diff --git a/jsr.json b/jsr.json index 5ca42bd104f8ed949cf3da85342551b764a7940e..ac3c21a0700fdbdddf9efe9f082a49176a1a568d 100644 --- a/jsr.json +++ b/jsr.json @@ -1,10 +1,10 @@ { "name": "@clo/react-mutation", - "version": "2.1.0", + "version": "3.0.0", "exports": { ".": "./src/mod.ts", - "./tanstack-query.ts": "./src/tanstack-query.ts", - "./object-path.ts": "./src/object-path.ts" + "./tanstack-query": "./src/tanstack-query.ts", + "./object-path": "./src/object-path.ts" }, "imports": { "@tanstack/react-query": "npm:@tanstack/react-query@^5", @@ -16,6 +16,9 @@ "README.md", "src/**/*", "test/**/*" + ], + "exclude": [ + "src/play.ts" ] }, "license": "ISC" diff --git a/package.json b/package.json index 38c3e0dc3845dda9753c63d662224c60b506ed2c..514be0ad76fa73d53ce78249051152f2e4677560 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "@clo/lib": "npm:@jsr/clo__lib@^3.0.0", "@std/assert": "npm:@jsr/std__assert@^1.0.17" }, + "exports": { + ".": "./src/mod.ts", + "./tanstack-query": "./src/tanstack-query.ts", + "./object-path": "./src/object-path.ts" + }, "devDependencies": { "@tanstack/react-query": "^5.90.20", "@testing-library/react": "^16.3.2", diff --git a/readme.changes.md b/readme.changes.md index 9db20ff46d01f56078d17796ad4d3814e54dc42c..3405cb0e4af03a236a614a1757c7069f8cfae7ee 100644 --- a/readme.changes.md +++ b/readme.changes.md @@ -1,5 +1,32 @@ # notable changes in React Mutation +## v3 + +### breaking + +- `id` is now required for all mutations. It must be unique per client; + duplicate registration throws in production and replaces the previous + registration in development (hot reload re-runs `define`). +- mutation arguments must be JSON serializable (`Json[]`) +- `handleUnauthenticated` and `onUnauthenticated` now receive a serializable + `MutationAction` (`{ id, description, args }`) instead of a description string + +### features + +- authentication integration for mutations (`auth: true`, `userContext`, + `isAllowed`, `handleUnauthenticated`) +- auth scopes for permission gates: declare `authScopes` on the client and + `auth: ""` on mutations +- `MutationClient.run(action)` re-runs a stored `MutationAction`, enabling + sign-in flows that resume the blocked mutation +- `MutationButton` hides non-allowed mutations that cannot route to the + sign-in flow; opt out with `showNotAllowed` + +### bugfixes + +- `runAsHeadlessPromise` correctly rethrows the underlying error +- clarify some intent in the readme + ## v2.1.0 ### features @@ -14,6 +41,7 @@ ### bugfixes - typescript violation not passing client context to optimistic handlers +- `onSuccess` prop split into `onSuccessUi` and `onSuccessData`. but it didn't even work before so this isn't breaking. ## v2 diff --git a/readme.md b/readme.md index 0abb4aca616540990fe6954c0ee1fa42964fd059..404d6b0c46e98fea1ba03b206306fff8de606514 100644 --- a/readme.md +++ b/readme.md @@ -28,10 +28,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an ```ts import { showToastUI } from "..."; import { MutationClient } from "@clo/react-mutation"; -import { - boundQueryClientGet, - queryClientOptimisticHelpers, -} from "@clo/react-mutation"; +import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query"; import { QueryClient } from "@tanstack/react-query"; const queryClient = new QueryClient(); @@ -39,7 +36,7 @@ export const mutations = new MutationClient({ // All properties in `context` are available within every function. context: { client: queryClient, - get: boundQueryClientGet(client), + get: boundQueryClientGet(queryClient), // Can add any easy helpers for your codebase. navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., @@ -62,6 +59,33 @@ export const mutations = new MutationClient({ reportSuccess(userFriendlySuccessMessage: string) { showToastUI("success", userFriendlyErrorMessage); }, + + // Optionally, your session system can be integrated to provide `auth: true` + // mutations that require a sign in before enabling. When a mutation cannot + // be performed due to missing auth, its `isAllowed` field reads false. + userContext: reactiveFromQueryCache( + queryClient, + queryCurrentUser, + (user) => user ? { user } : null, + ), + // Optionally, on top of `userContext`, custom subsets of authentication can be + // defined for different permission levels, for example admin-only. This is used + // at the call site with `auth: "admin"`. + authScopes: { + admin: reactiveFromQueryCache(queryClient, queryCurrentUser, (user) => !!user?.isAdmin), + }, + // On top of `userContext`, the session system can integrate its login flow to + // the mutation system. When configured, all authenticated mutations will be + // marked enabled but `!isAllowed`, except ones in scopes (so an admin + // mutation is still disabled and not allowed). When triggering a mutation, it + // is routed to this function instead. + handleUnauthenticated(action, mutation) { + // `action` is serializable. Could commit it to `sessionStorage` to survive + // a full-page sign-in flow, for example. + openLoginModal(`Sign in to ${action.description}`, () => { + mutations.run(action); + }); + }, }); ``` @@ -76,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... }); // The convention is to name handlers starting with `mut` const mutDeleteItem = mutations.define({ + // A stable identifier, unique per application. + id: "item/delete", // `mutate` comes first (for type inference), and // is only worried about syncing with the backend. async mutate(id: string) { @@ -217,6 +243,7 @@ will override the earlier calls by rolling back the optimistic state. ```tsx const mutUpdateField = mutations.define({ + id: "item/update-field", async mutate(id: string, value: string) { /* mutation */ }, optimistic({ args: [id, value], helpers }) { helpers.objSet(queryItem(id), ["value"], value); @@ -252,6 +279,7 @@ anything, `snapshot` can be used to detect no-op mutations. ```tsx const mutUpdateField = mutations.define({ + id: "item/update-field", async mutate(id: string, value: string) {/* mutation */}, optimistic({ args: [id, value], helpers }) { @@ -284,6 +312,8 @@ The `useMutate(null | Mutation)` react hook returns an object with the following - `run` (Function) this starts the mutation. - `clear` (Function) clear the status of sucess or error states. - `isPending` (boolean) if a loading indicator should be visible. +- `isDisabled` (boolean) if the underlying form/button should be disabled +- `isAllowed` (boolean) if the mutation is allowed considering authentication and pre-checks - `isSuccess` (boolean) if the mutation has succeeded. - `result` (Result or undefined) the successful result of the mutation. - `isError` (boolean) if the mutation failed. @@ -361,3 +391,47 @@ It can now be used for easy mutations: ; ``` + +## Authenticated Mutations + +Once the `MutationClient` is connected to the application's authentication +system, mutations themselves can declare `auth: true`. This does two things: + +- `useMutate` and mutation buttons read `isAllowed: false` while signed out. + Without a `handleUnauthenticated` handler they also disable; with one they + stay enabled so a click can route to the sign-in flow. +- The mutation implementation is given the `UserContext` to utilize. + +`describe` is the one function whose user context is nullable: it also runs +while signed out to build the `action.description` given to the sign-in flow. + +```tsx +const mutUpdateBio = mutations.define({ + id: "user/update-bio", + auth: true, + async mutate(bio: string) { + this.user; // if the user context, if needed + }, + optimistic({ args: [bio], helpers }) { + helpers.objSet(queryCurrentUser(), ["bio"], bio); + }, + // (...the rest...) +}); +``` + +Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped +mutation whose scope is unsatisfied always disables; it is never routed to +`handleUnauthenticated`. + +```tsx +const mutBanUser = mutations.define({ + id: "user/ban", + auth: "admin", + async mutate(targetId: string) {/* mutation */}, + // (...the rest...) +}); +``` + +By default, `MutationButton` will hide non-allowed mutations that cannot route +to the sign-in flow, which can be opted out by passing the `showNotAllowed` +prop. diff --git a/src/client.ts b/src/client.ts index 4e355003744687b164ff770be50f5b5904e3c7d3..2ccb96dfbadedd32b442f7740ea18ea7ed25b474 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,21 +1,30 @@ import { BlockingMutation, type MutationOptions } from "./mutation.ts"; -import type { Mutation } from "./types.ts"; +import type { Json, Mutation, MutationAction } from "./types.ts"; export interface MutationClientConfig { context: {}; optimisticHelpers: {}; + userContext: {}; + authScopes: string; } export type MutationClientFromConfig = MutationClient< Config["context"], - Config["optimisticHelpers"] + Config["optimisticHelpers"], + Config["userContext"], + Config["authScopes"] >; const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); +// Declared locally to avoid depending on node types. +declare const process: { env: { NODE_ENV?: string } }; + export interface MutationClientOptions< Context extends object, OptimisticHelpers extends object, + UserContext extends object = {}, + AuthScopes extends string = never, > { context: Context; getOptimisticHelpers: ( @@ -35,6 +44,30 @@ export interface MutationClientOptions< * @default true */ enabled?: boolean; + /** To support authenticated mutations, define a function that returns additional context. */ + userContext?: Reactive; + /** + * Subsets of authentication for different permission levels, used at the + * define site with `auth: ""`. A scoped mutation is allowed only when + * a user is available and its scope reads `true`; unlike `auth: true`, it is + * never routed to `handleUnauthenticated`. + */ + authScopes?: Record>; + /** + * Called instead of running an authenticated mutation when no user is + * available. The action is JSON serializable and can be stored, then passed + * to {@linkcode MutationClient.run} once the user signs in. + */ + handleUnauthenticated?: ( + this: { context: Context }, + action: MutationAction, + mutation: Mutation, + ) => void; +} + +export interface Reactive { + get: () => T; + sub: (onChange: () => void) => () => void; } export interface OptimisticEvents { @@ -45,16 +78,25 @@ export interface OptimisticEvents { export class MutationClient< Context extends object, OptimisticHelpers extends object, + UserContext extends object = {}, + AuthScopes extends string = never, > { context: Context; + userContext?: Reactive; + authScopes?: { [scope: string]: Reactive }; + handleUnauthenticated?: (action: MutationAction, mutation: Mutation) => void; getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; deepEquals: (a: unknown, b: unknown) => boolean; enabled: boolean; + #mutations: Map> = new Map(); - constructor(options: MutationClientOptions) { + constructor(options: MutationClientOptions) { this.context = options.context; + this.userContext = options.userContext; + this.authScopes = options.authScopes; + this.handleUnauthenticated = options.handleUnauthenticated; this.getOptimisticHelpers = options.getOptimisticHelpers; this.reportError = options.reportError; this.reportSuccess = options.reportSuccess; @@ -65,17 +107,49 @@ export class MutationClient< /** * Define a standard mutation. */ - define( + define( options: MutationOptions< Args, Result, - { context: Context; optimisticHelpers: OptimisticHelpers } + Auth, + { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } >, ): Mutation { - return new BlockingMutation< + if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) { + throw new Error(`Unknown auth scope "${options.auth}".`); + } + if (this.#mutations.has(options.id)) { + // Hot reload re-evaluates defining modules against the same client, so + // a duplicate is assumed to be a replacement unless this is positively + // a production build. The `typeof` guard supports unbundled browsers; + // bundled browser builds inline NODE_ENV but leave `typeof process` + // alone, which downgrades them to the replace-and-warn path. + if (typeof process !== "undefined" && process.env.NODE_ENV === "production") { + throw new Error(`Mutation id "${options.id}" is already registered.`); + } + console.error(`Mutation id "${options.id}" registered twice; assuming hot reload and replacing it.`); + } + const mutation = new BlockingMutation< Args, Result, - { context: Context; optimisticHelpers: OptimisticHelpers } - >(this, options); + Auth, + { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } + >( + this, + options, + ); + this.#mutations.set(options.id, mutation as unknown as Mutation); + return mutation; + } + + /** + * Re-run a mutation from a stored {@linkcode MutationAction}, such as one + * captured by `handleUnauthenticated` before the user signed in. Results are + * reported through the global handlers. + */ + run(action: Pick): void { + const mutation = this.#mutations.get(action.id); + if (!mutation) throw new Error(`Mutation id "${action.id}" is not registered.`); + mutation.run(...action.args); } } diff --git a/src/mod.ts b/src/mod.ts index 2e81282cdee0d6f63e3ad3efc83b7541dfb8e445..6b40331c4f11580fdb0d76b4abcec041aee1f9fd 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -3,6 +3,7 @@ export { type MutationClientConfig, type MutationClientFromConfig, type MutationClientOptions, + type Reactive, } from "./client.ts"; export type { MutationOptions, OptimisticContext } from "./mutation.ts"; export { @@ -16,4 +17,4 @@ export { type UseMutateResultBase, type UseMutateSuccess, } from "./react.ts"; -export type { Mutation, MutationEvent } from "./types.ts"; +export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts"; diff --git a/src/mutation.ts b/src/mutation.ts index 29266ff9e0eaeda75dda30e3cce91c65efdacb0c..f6291088e462f82347a5cb17b3aa37cc1d1ec2c8 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -1,7 +1,7 @@ import { message as errMessage } from "@clo/lib/error.ts"; -import type { MutationClient, MutationClientFromConfig } from "./client.ts"; +import type { MutationClientFromConfig, Reactive } from "./client.ts"; import type { MutationClientConfig } from "./client.ts"; -import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; +import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts"; /** * Argument to `defineBlocking`. @@ -10,10 +10,26 @@ import type { Mutation, MutationEvent, RunOptions } from "./types.ts"; * @template Config - global values and helpers from `MutationContext` */ export interface MutationOptions< - Args extends unknown[], + Args extends Json[], Result, + Auth extends boolean | string, Config extends MutationClientConfig, > { + /** + * Stable identifier, unique per client; duplicate registration throws in + * production and replaces the previous registration in development, where + * hot reload re-runs `define`. Identifies the mutation in a stored + * `MutationAction` so a call attempted while signed out can be restored + * with `MutationClient.run`. + */ + id: string; + /** + * Require a current user before the mutation can run. Passing one of the + * client's `authScopes` additionally requires that scope to read `true`. + */ + auth?: Auth; + /** Additional guard for fine-grained auth or domain-specific availability. */ + isAllowed?: Reactive; /** * This function is only responsible for performing the underlying API call, * syncronizing the optimistic state with reality. Throw on failure. A rest @@ -24,27 +40,31 @@ export interface MutationOptions< * In practice, optimistic context is never needed in this function, but it * is provided as the `this` value if you truly desire it. */ - mutate: (this: Config["context"], ...args: Args) => Promise; + mutate: (this: Context, ...args: Args) => Promise; + /** + * Specifying the optimistic strategy is required. To disable, pass an empty + * function with a comment to document why it isn't needed. + */ + optimistic: ( + context: OptimisticContext, + ) => void; /** * Used in error messages and debug tools. * Phrase it considering the template `Could not ${describe(...)}` + * + * Unlike every other function, user context is nullable: this also runs + * while signed out to build the `MutationAction` description given to the + * sign-in flow. */ - describe: string | ((context: Config["context"] & { args: Args }) => string); + describe: string | ((context: Context & { args: Args }) => string); /** * Used in success messages. * Phrase it as a complete success message: "Deleted Item" */ describeResult: | string - | ((context: Config["context"] & { args: Args; result: Result }) => string) + | ((context: Context & { args: Args; result: Result }) => string) | null; - /** - * Specifying the optimistic strategy is required. To disable, pass an empty - * function with a comment to document why it isn't needed. - */ - optimistic: ( - context: OptimisticContext, - ) => void; /** * If the optimistic updator function is perfect, then this may be set to false. * @default true @@ -55,7 +75,7 @@ export interface MutationOptions< * specifying, then all mutations of the same key will evaluate in serial, * but optimistic updates will apply instantly. */ - key?: (context: Config["context"] & { args: Args }) => string | string[]; + key?: (context: Context & { args: Args }) => string | string[]; /** * Enable debouncing with "last call wins" behavior. When rapid calls arrive, * the previous optimistic update is rolled back and the new one applied. @@ -76,14 +96,19 @@ export interface MutationOptions< * If the snapshots are equal (using deepEquals), the mutation is cancelled. * Only `onSettled` callbacks fire, not `onSuccess` or global handlers. */ - snapshot?: (context: Config["context"] & { args: Args }) => unknown; + snapshot?: (context: Context & { args: Args }) => unknown; } +type Context = + & Config["context"] + & (Auth extends false ? Partial : Config["userContext"]); + export type OptimisticContext< Args extends unknown[], Result, Config extends MutationClientConfig, -> = Config["context"] & { + Auth extends boolean | string, +> = Context & { args: Args; helpers: Config["optimisticHelpers"]; /** Add an event listener to roll back the update */ @@ -94,9 +119,11 @@ export type OptimisticContext< onRefetch: (cb: () => Promise) => void; }; -interface PendingDebouncedState { +interface PendingDebouncedState { /** Arguments from the most recent call */ args: Args; + /** Context captured when the most recent call was allowed */ + context: Context; /** Number of rollbacks the most recent call added */ rollbackCount: number; /** All pending promises from all superseded calls */ @@ -130,23 +157,24 @@ function unwrapMutationError(caught: unknown) { return { error: caught, description: null }; } -interface Channel { +interface Channel { listeners: Set<(update: MutationEvent) => void>; status: "idle" | "waiting" | "mutating" | "refetching" | "skipped"; rollbacks: Array<() => void>; refetches: Array<() => Promise>; - queue: Array>; + queue: Array>; // Shared optimistic helpers instance for the channel helpers: OptimisticHelpers | null; // Debounce state (only used if debounce option is set) debounceTimer: ReturnType | null; - pendingDebounced: PendingDebouncedState | null; + pendingDebounced: PendingDebouncedState | null; // Track when last debounced mutation executed (for debounceImmediate) lastDebouncedExecutionTime: number | null; } -interface Item { +interface Item { args: Args; + context: Context; rollbacks: number; onSuccess: Array<(result: Result) => void>; resolve: (result: Result) => void; @@ -154,29 +182,70 @@ interface Item { } export class BlockingMutation< - Args extends unknown[], + Args extends Json[], Result, + Auth extends boolean | string, Config extends MutationClientConfig, > implements Mutation { - #options: MutationOptions; + #options: MutationOptions; #client: MutationClientFromConfig; #channels: Map< string, - Channel + Channel> > = new Map(); client: MutationClientFromConfig; constructor( - client: MutationClient, - options: MutationOptions, + client: MutationClientFromConfig, + options: MutationOptions, ) { this.#options = options; this.#client = client; this.client = client; } + get id(): string { + return this.#options.id; + } + + #context(): Context { + return { + ...this.#client.context, + ...(this.#client.userContext?.get() ?? {}), + } as Context; + } + + #reportUnauthenticated(options: RunOptions, args: Args) { + const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated; + handler?.call( + this.#client, + { id: this.#options.id, description: this.describe(...args), args }, + this as unknown as Mutation, + ); + } + + isUnauthenticated(): boolean { + return this.#options.auth === true && this.#client.userContext?.get() == null; + } + + isAllowed(): boolean { + const { auth } = this.#options; + if (auth !== undefined && auth !== false && this.#client.userContext?.get() == null) return false; + if (typeof auth === "string" && !(this.#client.authScopes?.[auth]?.get() ?? false)) return false; + return this.#options.isAllowed?.get() ?? true; + } + + subscribeAllowed(cb: () => void): () => void { + const { auth } = this.#options; + const unsubscribes = [ + this.#options.isAllowed?.sub(cb), + typeof auth === "string" ? this.#client.authScopes?.[auth]?.sub(cb) : undefined, + ]; + return () => unsubscribes.forEach((unsubscribe) => unsubscribe?.()); + } + key(args: Args) { - const k = this.#options.key?.({ ...this.#client.context, args }) + const k = this.#options.key?.({ ...this.#context(), args }) ?? "shared"; return JSON.stringify(k); } @@ -211,7 +280,7 @@ export class BlockingMutation< } #notify( - channel: Channel, + channel: Channel>, status: MutationEvent["status"], result: Result | null = null, error: unknown = null, @@ -222,7 +291,7 @@ export class BlockingMutation< #setIdle( key: string, - channel: Channel, + channel: Channel>, ) { // Check if there are pending debounced calls waiting if (channel.pendingDebounced !== null) { @@ -250,7 +319,7 @@ export class BlockingMutation< describe(...args: Args): string { const { describe } = this.#options; return typeof describe === "function" - ? describe({ ...this.#client.context, args }) + ? describe({ ...this.#context(), args }) : describe; } @@ -258,7 +327,7 @@ export class BlockingMutation< const { describeResult } = this.#options; if (describeResult === null) return undefined; return typeof describeResult === "function" - ? describeResult({ ...this.#client.context, args, result }) + ? describeResult({ ...this.#context(), args, result }) : describeResult; } @@ -284,8 +353,17 @@ export class BlockingMutation< } const args = array.slice() as Args; - const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = args - .pop() as RunOptions; + const options = args.pop() as RunOptions; + const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = options; + + if (!this.isAllowed()) { + if (this.isUnauthenticated()) { + this.#reportUnauthenticated(options, args); + return Promise.reject(new Error("Mutation requires authentication.")); + } + return Promise.reject(new Error("Mutation is not allowed.")); + } + const promise = this.#runWithOptions(args, onRestore, true); return promise.then((result) => { // Call user handlers @@ -312,8 +390,22 @@ export class BlockingMutation< } const args = array.slice() as Args; - const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = args - .pop() as RunOptions; + const options = args.pop() as RunOptions; + const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = options; + if (!this.isAllowed()) { + if (this.isUnauthenticated()) { + this.#reportUnauthenticated(options, args); + return; + } + const error = new Error("Mutation is not allowed."); + onError?.(error); + onSettled?.({ status: "error", error }); + if (!onError) { + this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); + } + return; + } + const suppressAll = this.#options.debounceMs !== undefined && !onSuccessUi && !onError; const suppressGlobalSuccess = onSuccessUi !== undefined || suppressAll; const suppressGlobalError = onError !== undefined || suppressAll; @@ -357,6 +449,7 @@ export class BlockingMutation< } const key = this.key(args); const channel = this.#getOrPutChannel(key); + const context = this.#context(); // Check if debouncing is enabled if (this.#options.debounceMs !== undefined) { @@ -370,6 +463,7 @@ export class BlockingMutation< args, key, channel, + context, userOnRestore, !!shouldExecuteImmediate, suppressGlobalHandlers, @@ -378,7 +472,7 @@ export class BlockingMutation< // Take snapshot before optimistic update (if snapshot function defined) const beforeSnapshot = this.#options.snapshot - ? this.#options.snapshot.call(this.#client.context, { args }) + ? this.#options.snapshot.call(context, { ...context, args }) : undefined; // Create shared optimistic helpers instance for the channel if it doesn't exist @@ -416,7 +510,7 @@ export class BlockingMutation< try { this.#options.optimistic({ - ...this.#client.context, + ...context, args, helpers: channel.helpers, onRestore, @@ -452,8 +546,8 @@ export class BlockingMutation< // Take snapshot after optimistic update and check for no-op if (beforeSnapshot !== undefined) { const afterSnapshot = this.#options.snapshot!.call( - this.#client.context, - { args }, + context, + { ...context, args }, ); const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot); @@ -475,6 +569,7 @@ export class BlockingMutation< const { promise, resolve, reject } = Promise.withResolvers(); channel.queue.push({ args, + context, rollbacks, onSuccess, resolve, @@ -490,7 +585,7 @@ export class BlockingMutation< #executeNext( key: string, - channel: Channel, + channel: Channel>, ) { const item = channel.queue.shift(); if (!item) { @@ -498,11 +593,11 @@ export class BlockingMutation< return; } - const { args, onSuccess, resolve, reject } = item; + const { args, context, onSuccess, resolve, reject } = item; channel.status = "mutating"; this.#notify(channel, "mutating"); - this.#options.mutate.call(this.#client.context, ...args).then((result) => { + this.#options.mutate.call(context, ...args).then((result) => { // remove rollbacks and apply optimistic success handlers channel.rollbacks.splice(0, item.rollbacks); onSuccess.forEach((cb) => cb(result)); @@ -575,7 +670,8 @@ export class BlockingMutation< #runDebouncedAndReturn( args: Args, key: string, - channel: Channel, + channel: Channel>, + context: Context, userOnRestore: (() => void) | undefined, shouldExecuteImmediate: boolean, shouldCallGlobalHandler: boolean, @@ -584,7 +680,10 @@ export class BlockingMutation< const isFirstDebouncedCall = channel.pendingDebounced === null; let initialSnapshot: unknown; if (isFirstDebouncedCall && this.#options.snapshot) { - initialSnapshot = this.#options.snapshot.call(this.#client.context, { args }); + initialSnapshot = this.#options.snapshot.call( + context, + { ...context, args }, + ); } // If there's a pending debounced call, roll it back @@ -627,7 +726,7 @@ export class BlockingMutation< try { this.#options.optimistic({ - ...this.client.context, + ...context, args, helpers: channel.helpers, onRestore, @@ -664,8 +763,8 @@ export class BlockingMutation< // Check for no-op by comparing to initial snapshot if (this.#options.snapshot) { const currentSnapshot = this.#options.snapshot.call( - this.#client.context, - { args }, + context, + { ...context, args }, ); const snapshotToCompare = isFirstDebouncedCall ? initialSnapshot! @@ -708,6 +807,7 @@ export class BlockingMutation< // First debounced call channel.pendingDebounced = { args, + context, rollbackCount: rollbacks, pending: [{ resolve, reject }], onSuccess, @@ -721,6 +821,7 @@ export class BlockingMutation< } else { // Subsequent debounced call - update state channel.pendingDebounced.args = args; + channel.pendingDebounced.context = context; channel.pendingDebounced.rollbackCount = rollbacks; channel.pendingDebounced.pending.push({ resolve, reject }); channel.pendingDebounced.onSuccess = onSuccess; @@ -744,7 +845,7 @@ export class BlockingMutation< } #rollbackPendingDebounced( - channel: Channel, + channel: Channel>, ) { if (!channel.pendingDebounced) return; @@ -763,7 +864,7 @@ export class BlockingMutation< #enqueueDebouncedCall( key: string, - channel: Channel, + channel: Channel>, ) { // Clear timer channel.debounceTimer = null; @@ -774,7 +875,7 @@ export class BlockingMutation< return; } - const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; + const { args, context, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced; channel.pendingDebounced = null; // Track execution time for debounceImmediate @@ -822,6 +923,7 @@ export class BlockingMutation< // Add to queue (same structure as regular blocking mutation) channel.queue.push({ args, + context, rollbacks: rollbackCount, onSuccess, resolve: wrapperResolve, diff --git a/src/react.ts b/src/react.ts index 78f247a11f982a7d72bb1f63282fc03991ef10b3..6ac2d17d46b51b6a5ad58421d4df8bae05c53608 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,6 +1,5 @@ import { message as errMessage } from "@clo/lib/error.ts"; import type { Timer } from "@clo/lib/ts.ts"; -import { isDisabled } from "@testing-library/user-event/dist/cjs/utils/index.js"; import { type FC, type MouseEvent, @@ -30,6 +29,7 @@ export function useMutate< if (mutation !== observer.mutation) { observer.mutation = mutation; observer.reset(); + observer.subscribeAllowed(); } return observer.binding; } @@ -56,6 +56,8 @@ export interface UseMutateResultBase { args: Args | undefined; /** `true` when controls should be disabled */ isDisabled: boolean; + /** `true` when a mutation exists and its authentication requirement is satisfied. */ + isAllowed: boolean; } export interface UseMutateSuccess { @@ -121,7 +123,6 @@ type AnyMutationStateWithoutRun = isSuccess: boolean; isError: boolean; args: Args | undefined; - isDisabled: boolean; }; export type AnyMutationState = @@ -140,7 +141,6 @@ function initialState() { isError: false, isOptimisticData: false, args: undefined, - isDisabled: true, } as const; } @@ -148,6 +148,7 @@ class Observer { setRerender: (fn: number) => void; mutation: Mutation | null = null; unsubscribe: (() => void) | null = null; + unsubscribeAllowed: (() => void) | null = null; currentKey: string | null = null; pendingTimer: Timer | null = null; debounced: boolean = false; @@ -174,12 +175,30 @@ class Observer { reset() { this.unsubscribe?.(); + this.unsubscribeAllowed?.(); this.unsubscribe = null; + this.unsubscribeAllowed = null; this.currentKey = null; this.state = initialState(); this.debounced = false; } + subscribeAllowed() { + const mutation = this.mutation; + if (!mutation) return; + const rerenderIfWatched = () => { + if (this.watched.has("isAllowed") || this.watched.has("isDisabled")) { + this.setRerender(Math.random()); + } + }; + const unsubscribeMutation = mutation.subscribeAllowed(rerenderIfWatched); + const unsubscribeClient = mutation.client.userContext?.sub(rerenderIfWatched) ?? (() => {}); + this.unsubscribeAllowed = () => { + unsubscribeMutation(); + unsubscribeClient(); + }; + } + resetPending() { this.setState({ isPending: false }); if (this.pendingTimer) clearTimeout(this.pendingTimer); @@ -373,10 +392,18 @@ class Observer { self.watched.add("isMutating"); return self.state.isMutating; }, - // TODO: when auth drops this will be dependant on the auth status and isMutating + get isAllowed() { + self.watched.add("isAllowed"); + return self.mutation?.isAllowed() ?? false; + }, get isDisabled() { + self.watched.add("isDisabled"); self.watched.add("isMutating"); - return !self.mutation || (self.state.isMutating && !self.debounced); + const mutation = self.mutation; + if (!mutation || (self.state.isMutating && !self.debounced)) return true; + if (mutation.isAllowed()) return false; + // Unauthenticated clicks stay enabled when they can route to a sign-in flow. + return !(mutation.isUnauthenticated() && mutation.client.handleUnauthenticated); }, get isPending() { self.watched.add("isPending"); @@ -431,6 +458,8 @@ export interface MutationButtonProps { onSuccessUi?: (result: Result) => void; /** Does not prevent the global handler */ onSuccessData?: (result: Result) => void; + /** Called instead of running an authenticated mutation when no user is available. */ + onUnauthenticated?: RunOptions["onUnauthenticated"]; /** Global event handlers will still be called! */ onSettled?: ( @@ -442,6 +471,9 @@ export interface MutationButtonProps { error: unknown; }, ) => void; + + /** Setting this to true will opt out of the behavior that non-allowed buttons are hidden. */ + showNotAllowed?: boolean; } /** @@ -493,7 +525,9 @@ function GenericMutationButton< onError, onSuccessUi, onSuccessData, + onUnauthenticated, onSettled, + showNotAllowed, ...forwarded } = props; forwarded satisfies Omit>; @@ -508,9 +542,13 @@ function GenericMutationButton< if (!computedArgs || e.defaultPrevented) return; state.runWithOptions( ...computedArgs, - { onSuccessUi, onSuccessData, onError, onSettled }, + { onSuccessUi, onSuccessData, onError, onUnauthenticated, onSettled }, ); - }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, state]); + }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, onUnauthenticated, state]); + + // Buttons the user can never click are hidden; a signed-out `auth: true` + // button stays visible when it can route the click to the sign-in flow. + if (!showNotAllowed && !state.isAllowed && state.isDisabled) return null; // NOTE: the JSR has trouble with JSX syntax for some reason. return jsx( diff --git a/src/tanstack-query.ts b/src/tanstack-query.ts index 36200b4e7e9555ab4152354c7bb5f6de395b827b..15c7d00e640dc7432e20a106b54b0cb0f20bd454 100644 --- a/src/tanstack-query.ts +++ b/src/tanstack-query.ts @@ -1,5 +1,5 @@ import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query"; -import type { OptimisticEvents } from "./client.ts"; +import type { OptimisticEvents, Reactive } from "./client.ts"; import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts"; export type QueryKeyAndFn = { @@ -19,6 +19,33 @@ export function boundQueryClientGet( }; } +export function reactiveFromQueryCache( + client: QueryClient, + { queryKey }: QueryKeyAndFn, +): Reactive; +export function reactiveFromQueryCache( + client: QueryClient, + { queryKey }: QueryKeyAndFn, + deriver: (value: T | undefined) => R, +): Reactive; +export function reactiveFromQueryCache( + client: QueryClient, + { queryKey }: QueryKeyAndFn, + deriver: (value: T | undefined) => unknown = x => x, +): Reactive { + return { + get: () => deriver(client.getQueryData(queryKey)), + sub: (onChange) => { + const queryKeyJson = JSON.stringify(queryKey); + return client.getQueryCache().subscribe((event) => { + if (JSON.stringify(event.query.queryKey) === queryKeyJson) { + onChange(); + } + }); + }, + }; +} + class TanstackQueryOptimisticHelpers { #client: QueryClient; #onRefetch: OptimisticEvents["onRefetch"]; diff --git a/src/types.ts b/src/types.ts index 6e9ec6b8dfdb11aa1213fac8982316a1f6f79eb2..fd2d8326c42fc5cecc3630055822920749dc85b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,31 @@ import type { MutationClient } from "./client.ts"; +/** + * Mutation arguments must be JSON serializable so a call can be stored and + * replayed later, such as re-running a mutation after the user signs in. + */ +export type Json = + | string + | number + | boolean + | null + | Json[] + | { [key: string]: Json }; + +/** + * A serializable record of a mutation call. Produced when an authenticated + * mutation is attempted without a user; pass it to `MutationClient.run` + * after sign-in to re-run the call. + */ +export interface MutationAction { + id: string; + description: string; + args: Json[]; +} + export interface Mutation { + /** Unique identifier passed to `define`. */ + readonly id: string; /** Calling the mutation. Errors are turned into UI toasts. */ run(...args: Args): void; /** Calls the mutation with custom handlers that can suppress global handlers. */ @@ -25,7 +50,14 @@ export interface Mutation { ): () => void; describe(...args: Args): string; describeResult: ((args: Args, result: Result) => string | undefined) | null; - client: MutationClient; + /** + * `true` when an `auth: true` mutation has no user available. Scoped + * mutations are excluded; they disable instead of routing to the sign-in flow. + */ + isUnauthenticated(): boolean; + isAllowed(): boolean; + subscribeAllowed(cb: () => void): () => void; + client: MutationClient; } export interface RunOptions { @@ -47,8 +79,12 @@ export interface RunOptions { ) => void; /** Called when optimistic state is being restored/rolled back */ onRestore?: () => void; - /** @internal Suppresses global handlers for debounced mutations */ - __suppressGlobalForDebounce?: boolean; + /** + * Called instead of running an authenticated mutation when no user is + * available. The action is JSON serializable and can be passed to + * `MutationClient.run` after sign-in. + */ + onUnauthenticated?: (action: MutationAction, mutation: Mutation) => void; } export interface MutationEvent { diff --git a/test/auth.test.tsx b/test/auth.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65bc60d2c7c800be986d932d63fda61ecfd27b7e --- /dev/null +++ b/test/auth.test.tsx @@ -0,0 +1,568 @@ +import { + createMutationButton, + type MutationAction, + MutationClient, + type Reactive, + useMutate, +} from "@clo/react-mutation"; +import { assertEquals, assertThrows } from "@std/assert"; +import { act, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { FC, MouseEventHandler, ReactNode } from "react"; +import { test, vi } from "vitest"; +import { IterableStream } from "./share.ts"; + +interface User { + id: string; +} + +function mutableReactive(value: T): Reactive & { set(value: T): void } { + const listeners = new Set<() => void>(); + return { + get: () => value, + set(next) { + value = next; + listeners.forEach((listener) => listener()); + }, + sub(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +function createAuthClient(currentUser: () => User | null, withHandler = true, admin?: Reactive) { + const unauthenticated: MutationAction[] = []; + const errors: string[] = []; + const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }, "admin">({ + context: { contextValue: 42 }, + userContext: { + get: () => { + const user = currentUser(); + return user ? { user, accountId: "a1" } : null; + }, + sub: () => () => {}, + }, + authScopes: admin ? { admin } : undefined, + handleUnauthenticated: withHandler ? (action) => unauthenticated.push(action) : undefined, + getOptimisticHelpers: () => ({}), + reportError: (message) => errors.push(message), + }); + + return { client, unauthenticated, errors }; +} + +test("authenticated mutations receive user in every context", async () => { + const currentUser = { id: "u1" }; + const { client } = createAuthClient(() => currentUser); + const seen: string[] = []; + let snapshotValue = 0; + + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate: async function(value: number) { + seen.push(`mutate:${this.user?.id}:${this.accountId}:${this.contextValue}:${value}`); + return value + 1; + }, + key: ({ user, accountId, contextValue, args: [value] }) => { + seen.push(`key:${user?.id}:${accountId}:${contextValue}:${value}`); + return String(value); + }, + snapshot: ({ user, accountId, contextValue, args: [value] }) => { + seen.push(`snapshot:${user?.id}:${accountId}:${contextValue}:${value}`); + return snapshotValue; + }, + optimistic: ({ user, accountId, contextValue, args: [value] }) => { + seen.push(`optimistic:${user?.id}:${accountId}:${contextValue}:${value}`); + snapshotValue += 1; + }, + describe: ({ user, accountId, contextValue, args: [value] }) => { + seen.push(`describe:${user?.id}:${accountId}:${contextValue}:${value}`); + return "Test auth"; + }, + describeResult: ({ user, accountId, contextValue, args: [value], result }) => { + seen.push(`result:${user?.id}:${accountId}:${contextValue}:${value}:${result}`); + return "Tested auth"; + }, + refetchOnSuccess: false, + }); + + assertEquals(mutation.key([1]), "\"1\""); + assertEquals(mutation.describe(1), "Test auth"); + assertEquals(mutation.describeResult?.([1], 2), "Tested auth"); + assertEquals(await mutation.runAsHeadlessPromise(1, {}), 2); + + assertEquals(seen, [ + "key:u1:a1:42:1", + "describe:u1:a1:42:1", + "result:u1:a1:42:1:2", + "key:u1:a1:42:1", + "snapshot:u1:a1:42:1", + "optimistic:u1:a1:42:1", + "snapshot:u1:a1:42:1", + "mutate:u1:a1:42:1", + ]); +}); + +test("authenticated mutations do not run while unauthenticated", async () => { + const { client, unauthenticated } = createAuthClient(() => null); + const localUnauthenticated: string[] = []; + const mutate = vi.fn(async () => "ok"); + const optimistic = vi.fn(); + + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate, + optimistic, + describe: ({ user, contextValue }) => `Test auth as ${user?.id ?? "guest"}:${contextValue}`, + describeResult: "Tested auth", + }); + + mutation.runWithOptions({ + onUnauthenticated: () => localUnauthenticated.push("local"), + }); + mutation.run(); + await Promise.resolve(); + + assertEquals(localUnauthenticated, ["local"]); + assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth as guest:42", args: [] }]); + assertEquals(optimistic.mock.calls, []); + assertEquals(mutate.mock.calls, []); +}); + +test("duplicate mutation ids throw in production and replace in development", async () => { + const { client } = createAuthClient(() => null); + const options = { + id: "test-duplicate", + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test duplicate", + describeResult: null, + refetchOnSuccess: false, + }; + client.define(options); + + vi.stubEnv("NODE_ENV", "production"); + try { + assertThrows(() => client.define(options), Error, "Mutation id \"test-duplicate\" is already registered."); + } finally { + vi.unstubAllEnvs(); + } + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const replacement = vi.fn(async () => "ok"); + client.define({ ...options, mutate: replacement }); + assertEquals(errorSpy.mock.calls.length, 1); + + client.run({ id: "test-duplicate", args: [] }); + assertEquals(replacement.mock.calls, [[]]); + } finally { + errorSpy.mockRestore(); + } +}); + +test("unauthenticated actions survive JSON storage and restore after sign-in", async () => { + let currentUser: User | null = null; + const { client, unauthenticated } = createAuthClient(() => currentUser); + const mutate = vi.fn(async (value: number, note: string) => `${value}:${note}`); + + client.define({ + id: "test-restore", + auth: true, + mutate, + optimistic: () => {}, + describe: "Test restore", + describeResult: null, + refetchOnSuccess: false, + }); + + client.run({ id: "test-restore", args: [1, "hello"] }); + assertEquals(mutate.mock.calls, []); + assertEquals(unauthenticated, [{ id: "test-restore", description: "Test restore", args: [1, "hello"] }]); + + const stored: MutationAction = JSON.parse(JSON.stringify(unauthenticated[0])); + currentUser = { id: "u1" }; + client.run(stored); + await Promise.resolve(); + + assertEquals(mutate.mock.calls, [[1, "hello"]]); + assertThrows(() => client.run({ id: "test-unknown", args: [] }), Error, "not registered"); +}); + +test("useMutate derives isAllowed and isDisabled from authentication", async () => { + const user = userEvent.setup({ delay: null }); + let currentUser: User | null = null; + const { client } = createAuthClient(() => currentUser, false); + const s = new IterableStream(); + + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate: async () => (await s.next()).value, + optimistic: () => {}, + describe: "Test auth", + describeResult: "Tested auth", + }); + + let renders: Array<{ isAllowed: boolean; isDisabled: boolean; isMutating: boolean }> = []; + function TestComponent() { + const { run, isAllowed, isDisabled, isMutating } = useMutate(mutation); + renders.push({ isAllowed, isDisabled, isMutating }); + + return ; + } + + const view = render(); + assertEquals(renders, [{ isAllowed: false, isDisabled: true, isMutating: false }]); + renders = []; + + currentUser = { id: "u1" }; + view.rerender(); + assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]); + renders = []; + + await act(() => user.click(screen.getByTestId("run"))); + assertEquals(renders, [{ isAllowed: true, isDisabled: true, isMutating: true }]); + renders = []; + + await act(async () => { + s.push("ok"); + }); + assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]); +}); + +test("useMutate reacts to userContext changes", () => { + const userContext = mutableReactive<{ user: User; accountId: string } | null>(null); + const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }>({ + context: { contextValue: 42 }, + userContext, + getOptimisticHelpers: () => ({}), + reportError: () => {}, + }); + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test auth", + describeResult: "Tested auth", + }); + + let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { isAllowed, isDisabled } = useMutate(mutation); + renders.push({ isAllowed, isDisabled }); + return null; + } + + render(); + assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); + renders = []; + + act(() => userContext.set({ user: { id: "u1" }, accountId: "a1" })); + assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); +}); + +test("useMutate reacts to mutation isAllowed changes", () => { + const isAllowed = mutableReactive(false); + const { client } = createAuthClient(() => null); + const mutation = client.define({ + id: "test-allowed", + isAllowed, + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test allowed", + describeResult: "Tested allowed", + }); + + let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { isAllowed, isDisabled } = useMutate(mutation); + renders.push({ isAllowed, isDisabled }); + return null; + } + + render(); + assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); + renders = []; + + act(() => isAllowed.set(true)); + assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); +}); + +test("unauthenticated with a handler stays enabled and routes clicks to it", async () => { + const user = userEvent.setup({ delay: null }); + const { client, unauthenticated } = createAuthClient(() => null); + const mutate = vi.fn(async () => "ok"); + + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate, + optimistic: () => {}, + describe: "Test auth", + describeResult: "Tested auth", + }); + + let lastRender = { isAllowed: true, isDisabled: true }; + function TestComponent() { + const { run, isAllowed, isDisabled } = useMutate(mutation); + lastRender = { isAllowed, isDisabled }; + return ; + } + + render(); + assertEquals(lastRender, { isAllowed: false, isDisabled: false }); + + await act(() => user.click(screen.getByTestId("run"))); + assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth", args: [] }]); + assertEquals(mutate.mock.calls, []); +}); + +test("running while isAllowed is false reports an error instead of the sign-in flow", () => { + const isAllowed = mutableReactive(false); + const { client, unauthenticated, errors } = createAuthClient(() => null); + const mutate = vi.fn(async () => "ok"); + + const mutation = client.define({ + id: "test-not-allowed", + isAllowed, + mutate, + optimistic: () => {}, + describe: "Test allowed", + describeResult: null, + }); + + mutation.run(); + + assertEquals(unauthenticated, []); + assertEquals(mutate.mock.calls, []); + assertEquals(errors, ["Could not test allowed: Mutation is not allowed."]); +}); + +test("MutationButton hides authenticated mutations without a sign-in flow", async () => { + const user = userEvent.setup({ delay: null }); + const { client } = createAuthClient(() => null, false); + const mutate = vi.fn(async () => "ok"); + + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate, + optimistic: () => {}, + describe: "Test auth", + describeResult: "Tested auth", + }); + + const MutationButtonBase: FC<{ + children?: ReactNode; + disabled?: boolean; + isPending: boolean; + onClick: MouseEventHandler | undefined; + }> = ({ children, disabled, isPending, onClick }) => ( + + ); + const MutationButton = createMutationButton(MutationButtonBase); + + const view = render(run); + assertEquals(screen.queryByTestId("button"), null); + + view.rerender(run); + assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true); + await act(() => user.click(screen.getByTestId("button"))); + assertEquals(mutate.mock.calls, []); +}); + +test("auth scopes gate mutations without routing to sign-in", () => { + let currentUser: User | null = { id: "u1" }; + const admin = mutableReactive(false); + const { client, unauthenticated, errors } = createAuthClient(() => currentUser, true, admin); + const mutate = vi.fn(async () => "ok"); + + const mutation = client.define({ + id: "test-admin", + auth: "admin", + mutate, + optimistic: () => {}, + describe: "Test admin", + describeResult: null, + refetchOnSuccess: false, + }); + + assertEquals(mutation.isAllowed(), false); + assertEquals(mutation.isUnauthenticated(), false); + + mutation.run(); + assertEquals(unauthenticated, []); + assertEquals(mutate.mock.calls, []); + assertEquals(errors, ["Could not test admin: Mutation is not allowed."]); + + admin.set(true); + assertEquals(mutation.isAllowed(), true); + mutation.run(); + assertEquals(mutate.mock.calls, [[]]); + + currentUser = null; + assertEquals(mutation.isAllowed(), false); + assertEquals(mutation.isUnauthenticated(), false); +}); + +test("defining an unknown auth scope throws", () => { + const { client } = createAuthClient(() => null); + + assertThrows( + () => + client.define({ + id: "test-unknown-scope", + auth: "admin", + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test unknown scope", + describeResult: null, + }), + Error, + "Unknown auth scope \"admin\".", + ); +}); + +test("useMutate reacts to auth scope changes", () => { + const admin = mutableReactive(false); + const { client } = createAuthClient(() => ({ id: "u1" }), true, admin); + const mutation = client.define({ + id: "test-admin", + auth: "admin", + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test admin", + describeResult: null, + }); + + let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = []; + function TestComponent() { + const { isAllowed, isDisabled } = useMutate(mutation); + renders.push({ isAllowed, isDisabled }); + return null; + } + + render(); + assertEquals(renders, [{ isAllowed: false, isDisabled: true }]); + renders = []; + + act(() => admin.set(true)); + assertEquals(renders, [{ isAllowed: true, isDisabled: false }]); +}); + +test("MutationButton hides non-allowed mutations unless showNotAllowed", () => { + const admin = mutableReactive(false); + const { client } = createAuthClient(() => ({ id: "u1" }), true, admin); + const mutation = client.define({ + id: "test-admin", + auth: "admin", + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test admin", + describeResult: null, + }); + + const MutationButtonBase: FC<{ + children?: ReactNode; + disabled?: boolean; + isPending: boolean; + onClick: MouseEventHandler | undefined; + }> = ({ children, disabled, isPending, onClick }) => ( + + ); + const MutationButton = createMutationButton(MutationButtonBase); + + const view = render(run); + assertEquals(screen.queryByTestId("button"), null); + + view.rerender(run); + assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true); + + act(() => admin.set(true)); + assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false); +}); + +test("MutationButton stays visible for unauthenticated mutations with a sign-in flow", () => { + const { client } = createAuthClient(() => null); + const mutation = client.define({ + id: "test-auth", + auth: true, + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test auth", + describeResult: "Tested auth", + }); + + const MutationButtonBase: FC<{ + children?: ReactNode; + disabled?: boolean; + isPending: boolean; + onClick: MouseEventHandler | undefined; + }> = ({ children, disabled, isPending, onClick }) => ( + + ); + const MutationButton = createMutationButton(MutationButtonBase); + + render(run); + assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false); +}); + +test("mutation isAllowed can disable unrelated to auth", () => { + let allow = false; + const isAllowed: Reactive = { + get: () => allow, + sub: () => () => {}, + }; + const { client } = createAuthClient(() => null); + + const mutation = client.define({ + id: "test-allowed", + isAllowed, + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test allowed", + describeResult: "Tested allowed", + }); + + assertEquals(mutation.isAllowed(), false); + allow = true; + assertEquals(mutation.isAllowed(), true); +}); + +test("authenticated mutation isAllowed receives user context", () => { + let currentUser: User | null = { id: "u1" }; + let allow = true; + const isAllowed: Reactive = { + get: () => allow, + sub: () => () => {}, + }; + const { client } = createAuthClient(() => currentUser); + + const mutation = client.define({ + id: "test-auth-allowed", + auth: true, + isAllowed, + mutate: async () => "ok", + optimistic: () => {}, + describe: "Test allowed", + describeResult: "Tested allowed", + }); + + assertEquals(mutation.isAllowed(), true); + allow = false; + assertEquals(mutation.isAllowed(), false); + allow = true; + currentUser = null; + assertEquals(mutation.isAllowed(), false); +}); diff --git a/test/debounce.test.tsx b/test/debounce.test.tsx index a8946e2bd6c8940ebae201d59643c1af8e52355b..606fbaa18b5249026b131cdbc64988d39de71959 100644 --- a/test/debounce.test.tsx +++ b/test/debounce.test.tsx @@ -13,6 +13,7 @@ test("debounceMs: waits before first call", async () => { const s = new IterableStream(); const mutTest = client.define({ + id: "test-1", mutate: async () => { return (await s.next()).value; }, @@ -107,6 +108,7 @@ test("debounceMs: batch multiple calls together", async () => { const mutations: number[] = []; const mutTest = client.define({ + id: "test-2", mutate: async (k: number) => { mutations.push(k); return (await s.next()).value; @@ -188,6 +190,7 @@ test.todo("debounceImmediate runs the first one right away", async () => { const mutations: number[] = []; const mutTest = client.define({ + id: "test-3", mutate: async (k: number) => { mutations.push(k); return (await s.next()).value; diff --git a/test/optimistic.test.tsx b/test/optimistic.test.tsx index ea8a7be8587f58fd894938331fbfb7720ae17f99..065ca569ff464f864225f93f9b8b481a33040745 100644 --- a/test/optimistic.test.tsx +++ b/test/optimistic.test.tsx @@ -18,6 +18,7 @@ test.each([ const streamRefreshes = new IterableStream(); const mutTest = client.define({ + id: "test-1", mutate: async () => { return (await streamResults.next()).value; }, diff --git a/test/optimistic.ts b/test/optimistic.ts index ea3d33cf22e80fc60b0986c9af17a584c767aa00..ef305a277024a0c6a57fe498984d7173b2a58cb0 100644 --- a/test/optimistic.ts +++ b/test/optimistic.ts @@ -10,6 +10,7 @@ test("passes context value to functions", async () => { let values: number[] = []; const mutTest = client.define({ + id: "test-1", async mutate() { values.push(this.contextValue); calls.push("mutate"); diff --git a/test/ordering.test.ts b/test/ordering.test.ts index 0f4a1b07a4f927b7f36f0df70d7c3f5fbe4cf0d0..3e2d589986c5a59ac20caa5b46d6223ad7372cd8 100644 --- a/test/ordering.test.ts +++ b/test/ordering.test.ts @@ -11,6 +11,7 @@ test.each([ const { client, errorMessages, successMessages } = createTestMutationClient(); const calls: string[] = []; const mutTest = client.define({ + id: "test-1", mutate: async () => { calls.push("mutate"); await delay(100); @@ -60,6 +61,7 @@ test("error case: describe is called before rollback", async () => { let optimisticState = false; const mutTest = client.define({ + id: "test-2", mutate: async () => { calls.push("mutate"); await delay(100); @@ -122,6 +124,7 @@ test("error case with refetchOnSuccess=false still refetches", async () => { const calls: string[] = []; const mutTest = client.define({ + id: "test-3", mutate: async () => { calls.push("mutate"); await delay(100); @@ -155,6 +158,7 @@ test("multiple mutations in sequence: ordering preserved", async () => { const calls: string[] = []; const mutTest = client.define({ + id: "test-4", mutate: async (id: number) => { calls.push(`mutate-${id}`); await delay(100); @@ -226,6 +230,7 @@ test("error in second mutation: first stays applied, second rolls back", async ( let state = 0; const mutTest = client.define({ + id: "test-5", mutate: async (id: number) => { calls.push(`mutate-${id}`); await delay(100); @@ -275,6 +280,7 @@ test("onSuccess callback ordering relative to refetch", async () => { const calls: string[] = []; const mutTest = client.define({ + id: "test-6", mutate: async () => { calls.push("mutate"); await delay(100); @@ -321,6 +327,7 @@ test("runWithOptions callbacks: onError called before global handler", async () const calls: string[] = []; const mutTest = client.define({ + id: "test-7", mutate: async () => { await delay(100); throw new Error("Failed"); @@ -364,6 +371,7 @@ test("runWithOptions callbacks: onSuccess called before global handler", async ( const calls: string[] = []; const mutTest = client.define({ + id: "test-8", mutate: async () => { await delay(100); return "result"; @@ -407,6 +415,7 @@ test("optimistic update with no describeResult: no success message", async () => const calls: string[] = []; const mutTest = client.define({ + id: "test-9", mutate: async () => { await delay(100); return "result"; diff --git a/test/runWithOptions.test.tsx b/test/runWithOptions.test.tsx index 654953936781c9346c466fc4f64d413eff7d302d..d27333ecf5db204c9a1fbac5fa80cec6b9b37793 100644 --- a/test/runWithOptions.test.tsx +++ b/test/runWithOptions.test.tsx @@ -13,6 +13,7 @@ test("runWithOptions should allow react hook to do local handling", async () => const s = new IterableStream(); const mutTest = client.define({ + id: "test-1", mutate: async () => { return (await s.next()).value; }, @@ -74,6 +75,7 @@ test("runAsHeadlessPromise rejects with the underlying error", async () => { const localErrors: unknown[] = []; const mutTest = client.define({ + id: "test-2", mutate: async () => { return (await s.next()).value; }, diff --git a/test/setError.test.tsx b/test/setError.test.tsx index 650263ec1e03f7cc0ded262f57f4f1158bc39ee9..d18c414df77867ccd9cb035bef6bc69033b79680 100644 --- a/test/setError.test.tsx +++ b/test/setError.test.tsx @@ -12,6 +12,7 @@ test("setError should manually set error state on the hook", async () => { const { client, successMessages, errorMessages } = createTestMutationClient(); const mutTest = client.define({ + id: "test-1", mutate: async () => { return "success"; }, @@ -92,6 +93,7 @@ test("setError should override success state", async () => { const s = new IterableStream(); const mutTest = client.define({ + id: "test-2", mutate: async () => { return (await s.next()).value; }, @@ -206,6 +208,7 @@ test("setError should work with different error types", async () => { const { client } = createTestMutationClient(); const mutTest = client.define({ + id: "test-3", mutate: async () => { return "success"; }, diff --git a/test/snapshot.test.tsx b/test/snapshot.test.tsx index 9d1d1600f099f78618f334e003d77ec061877233..d532d7ab22df5af7418b2275a04789991cf6bd07 100644 --- a/test/snapshot.test.tsx +++ b/test/snapshot.test.tsx @@ -15,6 +15,7 @@ test("snapshot should skip no-op mutation", async () => { let state = { value: "initial" }; let failed = false; const mutUpdate = client.define({ + id: "test-1", mutate: async (newValue: string) => { failed = true; }, @@ -63,6 +64,7 @@ test("snapshot should allow mutation when value changes", async () => { let state = { value: "initial" }; const mutUpdate = client.define({ + id: "test-2", mutate: async (newValue: string) => { return (await s.next()).value; }, @@ -116,6 +118,7 @@ test("debounced snapshot should skip when final state equals initial", async () const mutations: string[] = []; const mutUpdate = client.define({ + id: "test-3", mutate: async (newValue: string) => { mutations.push(newValue); }, @@ -176,6 +179,7 @@ test("debounced snapshot should mutate when final differs from initial", async ( let state = { value: "initial" }; const mutUpdate = client.define({ + id: "test-4", mutate: async (newValue: string) => { return (await s.next()).value; }, diff --git a/test/tanstack-query-helpers.test.ts b/test/tanstack-query-helpers.test.ts index 4f4b7b16c7f73dbdc5efee82ad5c281cc44d9270..97da68e6d9267c82af87865e5f0634c12989f418 100644 --- a/test/tanstack-query-helpers.test.ts +++ b/test/tanstack-query-helpers.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert"; import { QueryClient, queryOptions } from "@tanstack/react-query"; import { test } from "vitest"; -import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; +import { queryClientOptimisticHelpers, reactiveFromQueryCache } from "../src/tanstack-query.ts"; interface TestData { name: string; @@ -89,6 +89,27 @@ test("helpers can be spread and retain bound this", () => { assertEquals(result?.count, 12); }); +test("reactiveFromQueryCache derives and subscribes to query data", () => { + const { client, queryTest } = createTestQueryClient(); + const reactive = reactiveFromQueryCache( + client, + queryTest, + (data) => data ? { name: data.name } : null, + ); + let changes = 0; + const unsubscribe = reactive.sub(() => changes += 1); + + assertEquals(reactive.get(), { name: "Test" }); + + client.setQueryData(queryTest.queryKey, { ...initialData, name: "Updated" }); + assertEquals(reactive.get(), { name: "Updated" }); + assertEquals(changes, 1); + + unsubscribe(); + client.setQueryData(queryTest.queryKey, { ...initialData, name: "Ignored" }); + assertEquals(changes, 1); +}); + // ============================================================================ // set() tests // ============================================================================ diff --git a/test/useMutate.test.tsx b/test/useMutate.test.tsx index 3a1f4a63a48d14d0f9be89338aba0451bfbd1137..b1e6a8816cf22ece19af5b2316a8effd4e1f01d8 100644 --- a/test/useMutate.test.tsx +++ b/test/useMutate.test.tsx @@ -14,6 +14,7 @@ test("useMutate - global error and success handling", async () => { const s = new IterableStream(); const mutTest = client.define({ + id: "test-1", mutate: async () => { return (await s.next()).value; }, @@ -99,6 +100,7 @@ test("useMutate - local error and success handling", async () => { const s = new IterableStream(); const mutTest = client.define({ + id: "test-2", mutate: async () => { return (await s.next()).value; }, @@ -282,6 +284,7 @@ test("MutationButton should allow args={null} to disable mutation runs", async ( const mutate = vi.fn(async (value: number) => value + 1); const mutTest = client.define({ + id: "test-3", mutate, describe: "Test the action", describeResult: "Tested the action",