| author | |
| committer | |
| log | 807bec69276df5a690e82e55070bf11342630a24 |
| tree | 0384674d7c675788782ff498913e509063be3fa3 |
| parent | 2aae3ac292152a63ba252833acb8a34f6d9881e6 |
| signature |
10 files changed, 253 insertions(+), 48 deletions(-)
jsr.json+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "3.0.0", | |
| 3 | "version": "3.0.1", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/mod.ts", |
| 6 | 6 | "./tanstack-query": "./src/tanstack-query.ts", |
package.json-3| ... | ... | @@ -1,11 +1,8 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | 3 | "private": true, |
| 4 | "description": "", | |
| 5 | 4 | "license": "ISC", |
| 6 | "author": "", | |
| 7 | 5 | "type": "module", |
| 8 | "main": "index.js", | |
| 9 | 6 | "scripts": { |
| 10 | 7 | "dev": "vitest dev", |
| 11 | 8 | "test": "vitest run", |
readme.changes.md+8| ... | ... | @@ -1,5 +1,13 @@ |
| 1 | 1 | # notable changes in React Mutation |
| 2 | 2 | |
| 3 | ## v3.0.1 | |
| 4 | ||
| 5 | ### bugfixes | |
| 6 | ||
| 7 | - resolve an SSR memory leak in `useMutate` | |
| 8 | - resolve `isAllowed`/`isDisabled` no longer updating after StrictMode's | |
| 9 | simulated remount permanently dropped the subscription | |
| 10 | ||
| 3 | 11 | ## v3 |
| 4 | 12 | |
| 5 | 13 | ### breaking |
src/client.ts+48-6| ... | ... | @@ -1,5 +1,34 @@ |
| 1 | 1 | import { BlockingMutation, type MutationOptions } from "./mutation.ts"; |
| 2 | import type { Json, Mutation, MutationAction } from "./types.ts"; | |
| 2 | import type { Mutation, MutationAction, Serializable, SerializableValue } from "./types.ts"; | |
| 3 | ||
| 4 | /** | |
| 5 | * Every element of a fixed argument tuple passes the {@link Serializable} check. | |
| 6 | * | |
| 7 | * The recursion deliberately walks head/tail and treats the non-tuple tail as | |
| 8 | * valid: any construct that inspects the whole tuple instead (`keyof`, `length`, | |
| 9 | * a mapped type, or `[...infer A]` reconstruction) forces resolution of the | |
| 10 | * `NoInfer`-wrapped argument type and misfires on the `async function` mutate | |
| 11 | * form, which is common. The cost is that a purely variadic mutate signature | |
| 12 | * (`mutate: (...xs: NonSerializable[])`) reaches the `true` base case unchecked; | |
| 13 | * fixed and leading-fixed parameters are still validated. | |
| 14 | */ | |
| 15 | type AllSerializable<Args extends unknown[]> = Args extends [infer Head, ...infer Tail] | |
| 16 | ? [Head] extends [Serializable<Head>] ? AllSerializable<Tail> : false | |
| 17 | : true; | |
| 18 | ||
| 19 | /** | |
| 20 | * Compile-time guard for the serializable-argument rule, expressed as `define`'s | |
| 21 | * trailing rest parameter: an empty tuple (nothing to pass) when the rule holds, | |
| 22 | * a one-element tuple otherwise, so a non-serializable `auth: true` call fails on | |
| 23 | * argument count at the call site. It rides a separate parameter rather than an | |
| 24 | * intersection on the options object, which is the sole inference source for | |
| 25 | * `Args` (via `mutate`) and collapses under intersection; and rather than a | |
| 26 | * type-parameter bound, which silently goes permissive against inferred | |
| 27 | * arguments. Scoped and unauthenticated mutations are unconstrained. | |
| 28 | */ | |
| 29 | type SerializableArgsGuard<Args extends unknown[], Auth> = Auth extends true ? AllSerializable<Args> extends true ? [] | |
| 30 | : [error: "auth:true mutation arguments must be serializable"] | |
| 31 | : []; | |
| 3 | 32 | |
| 4 | 33 | export interface MutationClientConfig { |
| 5 | 34 | context: {}; |
| ... | ... | @@ -61,7 +90,7 @@ export interface MutationClientOptions< |
| 61 | 90 | handleUnauthenticated?: ( |
| 62 | 91 | this: { context: Context }, |
| 63 | 92 | action: MutationAction, |
| 64 | mutation: Mutation<Json[], unknown>, | |
| 93 | mutation: Mutation<SerializableValue[], unknown>, | |
| 65 | 94 | ) => void; |
| 66 | 95 | } |
| 67 | 96 | |
| ... | ... | @@ -84,13 +113,13 @@ export class MutationClient< |
| 84 | 113 | context: Context; |
| 85 | 114 | userContext?: Reactive<UserContext | null>; |
| 86 | 115 | authScopes?: { [scope: string]: Reactive<boolean> }; |
| 87 | handleUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void; | |
| 116 | handleUnauthenticated?: (action: MutationAction, mutation: Mutation<SerializableValue[], unknown>) => void; | |
| 88 | 117 | getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers; |
| 89 | 118 | reportError: (message: string, error: unknown) => void; |
| 90 | 119 | reportSuccess?: (message: string) => void; |
| 91 | 120 | deepEquals: (a: unknown, b: unknown) => boolean; |
| 92 | 121 | enabled: boolean; |
| 93 | #mutations: Map<string, Mutation<Json[], unknown>> = new Map(); | |
| 122 | #mutations: Map<string, Mutation<SerializableValue[], unknown>> = new Map(); | |
| 94 | 123 | |
| 95 | 124 | constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) { |
| 96 | 125 | this.context = options.context; |
| ... | ... | @@ -106,14 +135,27 @@ export class MutationClient< |
| 106 | 135 | |
| 107 | 136 | /** |
| 108 | 137 | * Define a standard mutation. |
| 138 | * | |
| 139 | * Only an `auth: true` mutation attempted without a user builds a serializable | |
| 140 | * {@link MutationAction} for replay after sign-in, so only its arguments must | |
| 141 | * be serializable, enforced by {@link SerializableArgsGuard}. That check is a | |
| 142 | * {@link Serializable} mapped type rather than a concrete value type so | |
| 143 | * `interface` arguments type-check, and the leaf set stays extensible via | |
| 144 | * {@link SerializableLeaves}. Scoped (`auth: "<scope>"`) and unauthenticated | |
| 145 | * mutations never produce an action and leave their arguments unconstrained. | |
| 109 | 146 | */ |
| 110 | define<const Args extends Json[], Result, const Auth extends boolean | AuthScopes = false>( | |
| 147 | define< | |
| 148 | const Args extends unknown[], | |
| 149 | Result, | |
| 150 | const Auth extends boolean | AuthScopes = false, | |
| 151 | >( | |
| 111 | 152 | options: MutationOptions< |
| 112 | 153 | Args, |
| 113 | 154 | Result, |
| 114 | 155 | Auth, |
| 115 | 156 | { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes } |
| 116 | 157 | >, |
| 158 | ..._serializable: SerializableArgsGuard<NoInfer<Args>, NoInfer<Auth>> | |
| 117 | 159 | ): Mutation<Args, Result> { |
| 118 | 160 | if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) { |
| 119 | 161 | throw new Error(`Unknown auth scope "${options.auth}".`); |
| ... | ... | @@ -138,7 +180,7 @@ export class MutationClient< |
| 138 | 180 | this, |
| 139 | 181 | options, |
| 140 | 182 | ); |
| 141 | this.#mutations.set(options.id, mutation as unknown as Mutation<Json[], unknown>); | |
| 183 | this.#mutations.set(options.id, mutation as unknown as Mutation<SerializableValue[], unknown>); | |
| 142 | 184 | return mutation; |
| 143 | 185 | } |
| 144 | 186 |
src/mod.ts+9-1| ... | ... | @@ -19,4 +19,12 @@ export { |
| 19 | 19 | type UseMutateResultBase, |
| 20 | 20 | type UseMutateSuccess, |
| 21 | 21 | } from "./react.ts"; |
| 22 | export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts"; | |
| 22 | export type { | |
| 23 | Json, | |
| 24 | Mutation, | |
| 25 | MutationAction, | |
| 26 | MutationEvent, | |
| 27 | Serializable, | |
| 28 | SerializableLeaves, | |
| 29 | SerializableValue, | |
| 30 | } from "./types.ts"; |
src/mutation.ts+7-5| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { message as errMessage } from "@clo/lib/error.ts"; |
| 2 | 2 | import type { MutationClientFromConfig, Reactive } from "./client.ts"; |
| 3 | 3 | import type { MutationClientConfig } from "./client.ts"; |
| 4 | import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts"; | |
| 4 | import type { Mutation, MutationEvent, RunOptions, SerializableValue } from "./types.ts"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * Argument to `defineBlocking`. |
| ... | ... | @@ -10,7 +10,7 @@ import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts"; |
| 10 | 10 | * @template Config - global values and helpers from `MutationContext` |
| 11 | 11 | */ |
| 12 | 12 | export interface MutationOptions< |
| 13 | Args extends Json[], | |
| 13 | Args extends unknown[], | |
| 14 | 14 | Result, |
| 15 | 15 | Auth extends boolean | string, |
| 16 | 16 | Config extends MutationClientConfig, |
| ... | ... | @@ -182,7 +182,7 @@ interface Item<Args extends unknown[], Result, Context> { |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | 184 | export class BlockingMutation< |
| 185 | Args extends Json[], | |
| 185 | Args extends unknown[], | |
| 186 | 186 | Result, |
| 187 | 187 | Auth extends boolean | string, |
| 188 | 188 | Config extends MutationClientConfig, |
| ... | ... | @@ -219,8 +219,10 @@ export class BlockingMutation< |
| 219 | 219 | const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated; |
| 220 | 220 | handler?.call( |
| 221 | 221 | this.#client, |
| 222 | { id: this.#options.id, description: this.describe(...args), args }, | |
| 223 | this as unknown as Mutation<Json[], unknown>, | |
| 222 | // Reached only for `auth: true`, whose arguments the define-time guard | |
| 223 | // constrains to be serializable. | |
| 224 | { id: this.#options.id, description: this.describe(...args), args: args as SerializableValue[] }, | |
| 225 | this as unknown as Mutation<SerializableValue[], unknown>, | |
| 224 | 226 | ); |
| 225 | 227 | } |
| 226 | 228 |
src/react.ts+22-27| ... | ... | @@ -7,13 +7,15 @@ import { |
| 7 | 7 | type ReactNode, |
| 8 | 8 | useCallback, |
| 9 | 9 | useEffect, |
| 10 | useMemo, | |
| 10 | 11 | useRef, |
| 11 | 12 | useState, |
| 13 | useSyncExternalStore, | |
| 12 | 14 | } from "react"; |
| 13 | 15 | import { jsx } from "react/jsx-runtime"; |
| 14 | 16 | import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts"; |
| 15 | 17 | import { BlockingMutation, formatFriendlyError } from "./mutation.ts"; |
| 16 | import type { Json, Mutation, RunOptions } from "./types.ts"; | |
| 18 | import type { Mutation, RunOptions, SerializableValue } from "./types.ts"; | |
| 17 | 19 | |
| 18 | 20 | /** |
| 19 | 21 | * Subscribe to a mutation's status, as well as accessing a local `run` method. |
| ... | ... | @@ -27,12 +29,26 @@ export function useMutate< |
| 27 | 29 | ): UseMutateResult<Args, Result> { |
| 28 | 30 | const [_, setRerender] = useState(0); |
| 29 | 31 | const [observer] = useState(() => new Observer<Args, Result>(setRerender)); |
| 30 | useEffect(() => () => void observer.reset(), []); | |
| 31 | 32 | if (mutation !== observer.mutation) { |
| 32 | 33 | observer.mutation = mutation; |
| 33 | 34 | observer.reset(); |
| 34 | observer.subscribeAllowed(); | |
| 35 | 35 | } |
| 36 | // The allowed subscription lives in useSyncExternalStore: the server never | |
| 37 | // subscribes, and StrictMode's simulated remount resubscribes. | |
| 38 | const subscribeAllowed = useMemo(() => (onChange: () => void) => { | |
| 39 | if (!mutation) return () => {}; | |
| 40 | const unsubscribeMutation = mutation.subscribeAllowed(onChange); | |
| 41 | const unsubscribeClient = mutation.client.userContext?.sub(onChange) ?? (() => {}); | |
| 42 | return () => { | |
| 43 | unsubscribeMutation(); | |
| 44 | unsubscribeClient(); | |
| 45 | }; | |
| 46 | }, [mutation]); | |
| 47 | // Everything the subscription can change about a render, as one comparable value. | |
| 48 | const allowedSnapshot = () => | |
| 49 | mutation === null ? -1 : (mutation.isAllowed() ? 1 : 0) | (mutation.isUnauthenticated() ? 2 : 0); | |
| 50 | useSyncExternalStore(subscribeAllowed, allowedSnapshot, allowedSnapshot); | |
| 51 | useEffect(() => () => void observer.reset(), []); | |
| 36 | 52 | return observer.binding; |
| 37 | 53 | } |
| 38 | 54 | |
| ... | ... | @@ -62,11 +78,11 @@ export function bindAsyncCallback( |
| 62 | 78 | live.current = { callback, options }; |
| 63 | 79 | // A stable, unregistered mutation with no optimistic state; every field |
| 64 | 80 | // reads the ref so it tracks the latest callback and options. Args are |
| 65 | // never serialized here (no id, no auth replay), so the `Json[]` bound is | |
| 66 | // cast away locally. Empty describe/describeResult fall through to the | |
| 81 | // never serialized here (no id, no auth replay), so the serializable bound | |
| 82 | // is cast away locally. Empty describe/describeResult fall through to the | |
| 67 | 83 | // generic error message and no success toast, respectively. |
| 68 | 84 | const [mutation] = useState(() => |
| 69 | new BlockingMutation<Json[], Result, false, MutationClientConfig>( | |
| 85 | new BlockingMutation<SerializableValue[], Result, false, MutationClientConfig>( | |
| 70 | 86 | client as unknown as MutationClientFromConfig<MutationClientConfig>, |
| 71 | 87 | { |
| 72 | 88 | id: "", |
| ... | ... | @@ -196,7 +212,6 @@ class Observer<Args extends unknown[], Result> { |
| 196 | 212 | setRerender: (fn: number) => void; |
| 197 | 213 | mutation: Mutation<Args, Result> | null = null; |
| 198 | 214 | unsubscribe: (() => void) | null = null; |
| 199 | unsubscribeAllowed: (() => void) | null = null; | |
| 200 | 215 | currentKey: string | null = null; |
| 201 | 216 | pendingTimer: Timer | null = null; |
| 202 | 217 | debounced: boolean = false; |
| ... | ... | @@ -223,30 +238,12 @@ class Observer<Args extends unknown[], Result> { |
| 223 | 238 | |
| 224 | 239 | reset() { |
| 225 | 240 | this.unsubscribe?.(); |
| 226 | this.unsubscribeAllowed?.(); | |
| 227 | 241 | this.unsubscribe = null; |
| 228 | this.unsubscribeAllowed = null; | |
| 229 | 242 | this.currentKey = null; |
| 230 | 243 | this.state = initialState(); |
| 231 | 244 | this.debounced = false; |
| 232 | 245 | } |
| 233 | 246 | |
| 234 | subscribeAllowed() { | |
| 235 | const mutation = this.mutation; | |
| 236 | if (!mutation) return; | |
| 237 | const rerenderIfWatched = () => { | |
| 238 | if (this.watched.has("isAllowed") || this.watched.has("isDisabled")) { | |
| 239 | this.setRerender(Math.random()); | |
| 240 | } | |
| 241 | }; | |
| 242 | const unsubscribeMutation = mutation.subscribeAllowed(rerenderIfWatched); | |
| 243 | const unsubscribeClient = mutation.client.userContext?.sub(rerenderIfWatched) ?? (() => {}); | |
| 244 | this.unsubscribeAllowed = () => { | |
| 245 | unsubscribeMutation(); | |
| 246 | unsubscribeClient(); | |
| 247 | }; | |
| 248 | } | |
| 249 | ||
| 250 | 247 | resetPending() { |
| 251 | 248 | this.setState({ isPending: false }); |
| 252 | 249 | if (this.pendingTimer) clearTimeout(this.pendingTimer); |
| ... | ... | @@ -441,11 +438,9 @@ class Observer<Args extends unknown[], Result> { |
| 441 | 438 | return self.state.isMutating; |
| 442 | 439 | }, |
| 443 | 440 | get isAllowed() { |
| 444 | self.watched.add("isAllowed"); | |
| 445 | 441 | return self.mutation?.isAllowed() ?? false; |
| 446 | 442 | }, |
| 447 | 443 | get isDisabled() { |
| 448 | self.watched.add("isDisabled"); | |
| 449 | 444 | self.watched.add("isMutating"); |
| 450 | 445 | const mutation = self.mutation; |
| 451 | 446 | if (!mutation || (self.state.isMutating && !self.debounced)) return true; |
src/types.ts+42-4| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import type { MutationClient } from "./client.ts"; |
| 2 | 2 | |
| 3 | 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. | |
| 4 | * Plain JSON value. The default serializable leaf set, still exported for | |
| 5 | * consumers that want the exact JSON shape. | |
| 6 | 6 | */ |
| 7 | 7 | export type Json = |
| 8 | 8 | | string |
| ... | ... | @@ -12,6 +12,44 @@ export type Json = |
| 12 | 12 | | Json[] |
| 13 | 13 | | { [key: string]: Json }; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Registry of serializable leaf types. Empty by default, which reduces | |
| 17 | * {@link SerializableValue} to plain JSON. Downstream consumers whose transport | |
| 18 | * handles a superset of JSON augment this to register those types: | |
| 19 | * | |
| 20 | * ```ts | |
| 21 | * declare module "@clo/react-mutation" { | |
| 22 | * interface SerializableLeaves { file: File | Blob } | |
| 23 | * } | |
| 24 | * ``` | |
| 25 | */ | |
| 26 | export interface SerializableLeaves {} | |
| 27 | ||
| 28 | type Leaf = string | number | boolean | null | undefined | SerializableLeaves[keyof SerializableLeaves]; | |
| 29 | ||
| 30 | /** | |
| 31 | * Validates that `T` is serializable: every leaf is a registered {@link Leaf} | |
| 32 | * and nested objects/arrays recurse. Enforced at `define` for `auth: true` | |
| 33 | * arguments; unlike a concrete value type it accepts `interface` arguments, | |
| 34 | * because the object case is a mapped type over `keyof T` and so needs no index | |
| 35 | * signature the way `{ [k: string]: ... }` would. Any non-serializable member | |
| 36 | * (a function/method, an unregistered class) collapses to `never`. | |
| 37 | */ | |
| 38 | export type Serializable<T> = [T] extends [Leaf] ? T | |
| 39 | : T extends (...args: never[]) => unknown ? never | |
| 40 | : T extends readonly unknown[] ? { [I in keyof T]: Serializable<T[I]> } | |
| 41 | : T extends object ? { [K in keyof T]: Serializable<T[K]> } | |
| 42 | : never; | |
| 43 | ||
| 44 | /** | |
| 45 | * A concrete serializable value, driven by the {@link SerializableLeaves} | |
| 46 | * registry. Stored in a {@link MutationAction} for replay after sign-in. | |
| 47 | */ | |
| 48 | export type SerializableValue = | |
| 49 | | Leaf | |
| 50 | | SerializableValue[] | |
| 51 | | { [key: string]: SerializableValue }; | |
| 52 | ||
| 15 | 53 | /** |
| 16 | 54 | * A serializable record of a mutation call. Produced when an authenticated |
| 17 | 55 | * mutation is attempted without a user; pass it to `MutationClient.run` |
| ... | ... | @@ -20,7 +58,7 @@ export type Json = |
| 20 | 58 | export interface MutationAction { |
| 21 | 59 | id: string; |
| 22 | 60 | description: string; |
| 23 | args: Json[]; | |
| 61 | args: SerializableValue[]; | |
| 24 | 62 | } |
| 25 | 63 | |
| 26 | 64 | export interface Mutation<Args extends unknown[], Result> { |
| ... | ... | @@ -84,7 +122,7 @@ export interface RunOptions<Result> { |
| 84 | 122 | * available. The action is JSON serializable and can be passed to |
| 85 | 123 | * `MutationClient.run` after sign-in. |
| 86 | 124 | */ |
| 87 | onUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void; | |
| 125 | onUnauthenticated?: (action: MutationAction, mutation: Mutation<SerializableValue[], unknown>) => void; | |
| 88 | 126 | } |
| 89 | 127 | |
| 90 | 128 | export interface MutationEvent<Result> { |
test/auth.test.tsx+49| ... | ... | @@ -566,3 +566,52 @@ test("authenticated mutation isAllowed receives user context", () => { |
| 566 | 566 | currentUser = null; |
| 567 | 567 | assertEquals(mutation.isAllowed(), false); |
| 568 | 568 | }); |
| 569 | ||
| 570 | // Type-level: the serializable-argument constraint applies only to `auth: true` | |
| 571 | // mutations, which are the only ones captured into a replayable MutationAction. | |
| 572 | // Guarded by the package's `tsc --noEmit`; never executed. | |
| 573 | // deno-lint-ignore no-unused-vars | |
| 574 | function _serializableArgsConstraint() { | |
| 575 | const { client } = createAuthClient(() => null, false, { get: () => true, sub: () => () => {} }); | |
| 576 | const base = { optimistic: () => {}, describe: "x", describeResult: null } as const; | |
| 577 | ||
| 578 | // Non-auth mutations accept non-serializable arguments (File, interfaces). | |
| 579 | client.define({ id: "t1", mutate: async (_file: File) => {}, ...base }); | |
| 580 | ||
| 581 | // Scoped mutations never serialize either, so they are unconstrained. | |
| 582 | client.define({ id: "t2", auth: "admin", mutate: async (_file: File) => {}, ...base }); | |
| 583 | ||
| 584 | // `auth: true` with serializable arguments is allowed. | |
| 585 | client.define({ id: "t3", auth: true, mutate: async (_id: string) => {}, ...base }); | |
| 586 | ||
| 587 | // `auth: true` with `interface`-typed arguments is allowed. A concrete `Json` | |
| 588 | // value type would reject these for lacking an index signature; the mapped | |
| 589 | // `Serializable` check accepts them. | |
| 590 | interface Payload { | |
| 591 | name: string; | |
| 592 | nested: { count: number; tags: string[] }; | |
| 593 | } | |
| 594 | client.define({ id: "t4", auth: true, mutate: async (_p: Payload) => {}, ...base }); | |
| 595 | ||
| 596 | // `auth: true` with a non-serializable argument is rejected. A function is | |
| 597 | // never a registered leaf, so this holds regardless of augmentation below. | |
| 598 | // @ts-expect-error a function is not serializable | |
| 599 | client.define({ id: "t5", auth: true, mutate: async (_fn: () => void) => {}, ...base }); | |
| 600 | ||
| 601 | // Leading fixed arguments are checked even alongside a rest parameter. | |
| 602 | // @ts-expect-error a non-serializable leading argument is rejected | |
| 603 | client.define({ id: "t7", auth: true, mutate: async (_fn: () => void, ..._rest: number[]) => {}, ...base }); | |
| 604 | ||
| 605 | // A `File` argument is serializable here only because the `SerializableLeaves` | |
| 606 | // augmentation below registers it; the library does not hard-code it. | |
| 607 | client.define({ id: "t6", auth: true, mutate: async (_file: File) => {}, ...base }); | |
| 608 | } | |
| 609 | ||
| 610 | // Type-level: consumers extend the serializable leaf set through the | |
| 611 | // `SerializableLeaves` registry, so a superset value (here `File`) becomes a | |
| 612 | // valid `auth: true` argument without the library forcing the type. | |
| 613 | declare module "../src/types.ts" { | |
| 614 | interface SerializableLeaves { | |
| 615 | file: File; | |
| 616 | } | |
| 617 | } |
test/useMutate.test.tsx+67-1| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | import { assertEquals } from "@std/assert"; |
| 2 | 2 | import { act, render, screen } from "@testing-library/react"; |
| 3 | 3 | import { userEvent } from "@testing-library/user-event"; |
| 4 | import type { FC, MouseEventHandler, ReactNode } from "react"; | |
| 4 | import { type FC, type MouseEventHandler, type ReactNode, StrictMode } from "react"; | |
| 5 | import { renderToString } from "react-dom/server"; | |
| 5 | 6 | import { test, vi } from "vitest"; |
| 7 | import { MutationClient } from "../src/client.ts"; | |
| 6 | 8 | import { createMutationButton, useMutate } from "../src/react.ts"; |
| 7 | 9 | import { createTestMutationClient, IterableStream } from "./share.ts"; |
| 8 | 10 | |
| ... | ... | @@ -321,3 +323,67 @@ test("MutationButton should allow args={null} to disable mutation runs", async ( |
| 321 | 323 | |
| 322 | 324 | assertEquals(mutate.mock.calls, []); |
| 323 | 325 | }); |
| 326 | ||
| 327 | function countedAllowedMutation() { | |
| 328 | let activeSubscriptions = 0; | |
| 329 | const subscribers = new Set<() => void>(); | |
| 330 | let allowed = true; | |
| 331 | const sub = (cb: () => void) => { | |
| 332 | activeSubscriptions++; | |
| 333 | subscribers.add(cb); | |
| 334 | return () => { | |
| 335 | activeSubscriptions--; | |
| 336 | subscribers.delete(cb); | |
| 337 | }; | |
| 338 | }; | |
| 339 | const client = new MutationClient({ | |
| 340 | context: {}, | |
| 341 | userContext: { get: () => null, sub }, | |
| 342 | getOptimisticHelpers: () => ({}), | |
| 343 | reportError: () => {}, | |
| 344 | }); | |
| 345 | const mutTest = client.define({ | |
| 346 | id: "test-allowed-subscription", | |
| 347 | mutate: async () => "ok", | |
| 348 | describe: "Test", | |
| 349 | describeResult: null, | |
| 350 | optimistic: () => {}, | |
| 351 | isAllowed: { get: () => allowed, sub }, | |
| 352 | }); | |
| 353 | const setAllowed = (next: boolean) => { | |
| 354 | allowed = next; | |
| 355 | subscribers.forEach((cb) => cb()); | |
| 356 | }; | |
| 357 | return { mutTest, setAllowed, activeSubscriptions: () => activeSubscriptions }; | |
| 358 | } | |
| 359 | ||
| 360 | test("server rendering never subscribes to allowed state", () => { | |
| 361 | const { mutTest, activeSubscriptions } = countedAllowedMutation(); | |
| 362 | function App() { | |
| 363 | useMutate(mutTest); | |
| 364 | return null; | |
| 365 | } | |
| 366 | for (let i = 0; i < 3; i++) renderToString(<App />); | |
| 367 | assertEquals(activeSubscriptions(), 0); | |
| 368 | }); | |
| 369 | ||
| 370 | test("StrictMode keeps the allowed subscription alive", () => { | |
| 371 | const { mutTest, setAllowed, activeSubscriptions } = countedAllowedMutation(); | |
| 372 | let lastIsAllowed: boolean | null = null; | |
| 373 | function App() { | |
| 374 | lastIsAllowed = useMutate(mutTest).isAllowed; | |
| 375 | return null; | |
| 376 | } | |
| 377 | const view = render( | |
| 378 | <StrictMode> | |
| 379 | <App /> | |
| 380 | </StrictMode>, | |
| 381 | ); | |
| 382 | assertEquals(lastIsAllowed, true); | |
| 383 | ||
| 384 | act(() => setAllowed(false)); | |
| 385 | assertEquals(lastIsAllowed, false); | |
| 386 | ||
| 387 | view.unmount(); | |
| 388 | assertEquals(activeSubscriptions(), 0); | |
| 389 | }); |