authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-16 17:07:28-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-24 13:49:20-07:00
log807bec69276df5a690e82e55070bf11342630a24
tree0384674d7c675788782ff498913e509063be3fa3
parent2aae3ac292152a63ba252833acb8a34f6d9881e6
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

fix: resolve bugs!


10 files changed, 253 insertions(+), 48 deletions(-)

jsr.json+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "3.0.0",3 "version": "3.0.1",
4 "exports": {4 "exports": {
5 ".": "./src/mod.ts",5 ".": "./src/mod.ts",
6 "./tanstack-query": "./src/tanstack-query.ts",6 "./tanstack-query": "./src/tanstack-query.ts",
package.json-3
...@@ -1,11 +1,8 @@...@@ -1,11 +1,8 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "private": true,3 "private": true,
4 "description": "",
5 "license": "ISC",4 "license": "ISC",
6 "author": "",
7 "type": "module",5 "type": "module",
8 "main": "index.js",
9 "scripts": {6 "scripts": {
10 "dev": "vitest dev",7 "dev": "vitest dev",
11 "test": "vitest run",8 "test": "vitest run",
readme.changes.md+8
...@@ -1,5 +1,13 @@...@@ -1,5 +1,13 @@
1# notable changes in React Mutation1# notable changes in React Mutation
22
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## v311## v3
412
5### breaking13### breaking
src/client.ts+48-6
...@@ -1,5 +1,34 @@...@@ -1,5 +1,34 @@
1import { BlockingMutation, type MutationOptions } from "./mutation.ts";1import { BlockingMutation, type MutationOptions } from "./mutation.ts";
2import type { Json, Mutation, MutationAction } from "./types.ts";2import 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 */
15type 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 */
29type SerializableArgsGuard<Args extends unknown[], Auth> = Auth extends true ? AllSerializable<Args> extends true ? []
30 : [error: "auth:true mutation arguments must be serializable"]
31 : [];
332
4export interface MutationClientConfig {33export interface MutationClientConfig {
5 context: {};34 context: {};
...@@ -61,7 +90,7 @@ export interface MutationClientOptions<...@@ -61,7 +90,7 @@ export interface MutationClientOptions<
61 handleUnauthenticated?: (90 handleUnauthenticated?: (
62 this: { context: Context },91 this: { context: Context },
63 action: MutationAction,92 action: MutationAction,
64 mutation: Mutation<Json[], unknown>,93 mutation: Mutation<SerializableValue[], unknown>,
65 ) => void;94 ) => void;
66}95}
6796
...@@ -84,13 +113,13 @@ export class MutationClient<...@@ -84,13 +113,13 @@ export class MutationClient<
84 context: Context;113 context: Context;
85 userContext?: Reactive<UserContext | null>;114 userContext?: Reactive<UserContext | null>;
86 authScopes?: { [scope: string]: Reactive<boolean> };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 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;117 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
89 reportError: (message: string, error: unknown) => void;118 reportError: (message: string, error: unknown) => void;
90 reportSuccess?: (message: string) => void;119 reportSuccess?: (message: string) => void;
91 deepEquals: (a: unknown, b: unknown) => boolean;120 deepEquals: (a: unknown, b: unknown) => boolean;
92 enabled: boolean;121 enabled: boolean;
93 #mutations: Map<string, Mutation<Json[], unknown>> = new Map();122 #mutations: Map<string, Mutation<SerializableValue[], unknown>> = new Map();
94123
95 constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) {124 constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) {
96 this.context = options.context;125 this.context = options.context;
...@@ -106,14 +135,27 @@ export class MutationClient<...@@ -106,14 +135,27 @@ export class MutationClient<
106135
107 /**136 /**
108 * Define a standard mutation.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 options: MutationOptions<152 options: MutationOptions<
112 Args,153 Args,
113 Result,154 Result,
114 Auth,155 Auth,
115 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }156 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }
116 >,157 >,
158 ..._serializable: SerializableArgsGuard<NoInfer<Args>, NoInfer<Auth>>
117 ): Mutation<Args, Result> {159 ): Mutation<Args, Result> {
118 if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) {160 if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) {
119 throw new Error(`Unknown auth scope "${options.auth}".`);161 throw new Error(`Unknown auth scope "${options.auth}".`);
...@@ -138,7 +180,7 @@ export class MutationClient<...@@ -138,7 +180,7 @@ export class MutationClient<
138 this,180 this,
139 options,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 return mutation;184 return mutation;
143 }185 }
144186
src/mod.ts+9-1
...@@ -19,4 +19,12 @@ export {...@@ -19,4 +19,12 @@ export {
19 type UseMutateResultBase,19 type UseMutateResultBase,
20 type UseMutateSuccess,20 type UseMutateSuccess,
21} from "./react.ts";21} from "./react.ts";
22export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts";22export 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,7 +1,7 @@
1import { message as errMessage } from "@clo/lib/error.ts";1import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClientFromConfig, Reactive } from "./client.ts";2import type { MutationClientFromConfig, Reactive } from "./client.ts";
3import type { MutationClientConfig } from "./client.ts";3import type { MutationClientConfig } from "./client.ts";
4import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts";4import type { Mutation, MutationEvent, RunOptions, SerializableValue } from "./types.ts";
55
6/**6/**
7 * Argument to `defineBlocking`.7 * Argument to `defineBlocking`.
...@@ -10,7 +10,7 @@ import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts";...@@ -10,7 +10,7 @@ import type { Json, 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 Json[],13 Args extends unknown[],
14 Result,14 Result,
15 Auth extends boolean | string,15 Auth extends boolean | string,
16 Config extends MutationClientConfig,16 Config extends MutationClientConfig,
...@@ -182,7 +182,7 @@ interface Item<Args extends unknown[], Result, Context> {...@@ -182,7 +182,7 @@ interface Item<Args extends unknown[], Result, Context> {
182}182}
183183
184export class BlockingMutation<184export class BlockingMutation<
185 Args extends Json[],185 Args extends unknown[],
186 Result,186 Result,
187 Auth extends boolean | string,187 Auth extends boolean | string,
188 Config extends MutationClientConfig,188 Config extends MutationClientConfig,
...@@ -219,8 +219,10 @@ export class BlockingMutation<...@@ -219,8 +219,10 @@ export class BlockingMutation<
219 const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated;219 const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated;
220 handler?.call(220 handler?.call(
221 this.#client,221 this.#client,
222 { id: this.#options.id, description: this.describe(...args), args },222 // Reached only for `auth: true`, whose arguments the define-time guard
223 this as unknown as Mutation<Json[], unknown>,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 }
226228
src/react.ts+22-27
...@@ -7,13 +7,15 @@ import {...@@ -7,13 +7,15 @@ import {
7 type ReactNode,7 type ReactNode,
8 useCallback,8 useCallback,
9 useEffect,9 useEffect,
10 useMemo,
10 useRef,11 useRef,
11 useState,12 useState,
13 useSyncExternalStore,
12} from "react";14} from "react";
13import { jsx } from "react/jsx-runtime";15import { jsx } from "react/jsx-runtime";
14import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts";16import type { MutationClient, MutationClientConfig, MutationClientFromConfig } from "./client.ts";
15import { BlockingMutation, formatFriendlyError } from "./mutation.ts";17import { BlockingMutation, formatFriendlyError } from "./mutation.ts";
16import type { Json, Mutation, RunOptions } from "./types.ts";18import type { Mutation, RunOptions, SerializableValue } from "./types.ts";
1719
18/**20/**
19 * Subscribe to a mutation's status, as well as accessing a local `run` method.21 * Subscribe to a mutation's status, as well as accessing a local `run` method.
...@@ -27,12 +29,26 @@ export function useMutate<...@@ -27,12 +29,26 @@ export function useMutate<
27): UseMutateResult<Args, Result> {29): UseMutateResult<Args, Result> {
28 const [_, setRerender] = useState(0);30 const [_, setRerender] = useState(0);
29 const [observer] = useState(() => new Observer<Args, Result>(setRerender));31 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
30 useEffect(() => () => void observer.reset(), []);
31 if (mutation !== observer.mutation) {32 if (mutation !== observer.mutation) {
32 observer.mutation = mutation;33 observer.mutation = mutation;
33 observer.reset();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 return observer.binding;52 return observer.binding;
37}53}
3854
...@@ -62,11 +78,11 @@ export function bindAsyncCallback(...@@ -62,11 +78,11 @@ export function bindAsyncCallback(
62 live.current = { callback, options };78 live.current = { callback, options };
63 // A stable, unregistered mutation with no optimistic state; every field79 // A stable, unregistered mutation with no optimistic state; every field
64 // reads the ref so it tracks the latest callback and options. Args are80 // 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 is81 // never serialized here (no id, no auth replay), so the serializable bound
66 // cast away locally. Empty describe/describeResult fall through to the82 // is cast away locally. Empty describe/describeResult fall through to the
67 // generic error message and no success toast, respectively.83 // generic error message and no success toast, respectively.
68 const [mutation] = useState(() =>84 const [mutation] = useState(() =>
69 new BlockingMutation<Json[], Result, false, MutationClientConfig>(85 new BlockingMutation<SerializableValue[], Result, false, MutationClientConfig>(
70 client as unknown as MutationClientFromConfig<MutationClientConfig>,86 client as unknown as MutationClientFromConfig<MutationClientConfig>,
71 {87 {
72 id: "",88 id: "",
...@@ -196,7 +212,6 @@ class Observer<Args extends unknown[], Result> {...@@ -196,7 +212,6 @@ class Observer<Args extends unknown[], Result> {
196 setRerender: (fn: number) => void;212 setRerender: (fn: number) => void;
197 mutation: Mutation<Args, Result> | null = null;213 mutation: Mutation<Args, Result> | null = null;
198 unsubscribe: (() => void) | null = null;214 unsubscribe: (() => void) | null = null;
199 unsubscribeAllowed: (() => void) | null = null;
200 currentKey: string | null = null;215 currentKey: string | null = null;
201 pendingTimer: Timer | null = null;216 pendingTimer: Timer | null = null;
202 debounced: boolean = false;217 debounced: boolean = false;
...@@ -223,30 +238,12 @@ class Observer<Args extends unknown[], Result> {...@@ -223,30 +238,12 @@ class Observer<Args extends unknown[], Result> {
223238
224 reset() {239 reset() {
225 this.unsubscribe?.();240 this.unsubscribe?.();
226 this.unsubscribeAllowed?.();
227 this.unsubscribe = null;241 this.unsubscribe = null;
228 this.unsubscribeAllowed = null;
229 this.currentKey = null;242 this.currentKey = null;
230 this.state = initialState();243 this.state = initialState();
231 this.debounced = false;244 this.debounced = false;
232 }245 }
233246
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 resetPending() {247 resetPending() {
251 this.setState({ isPending: false });248 this.setState({ isPending: false });
252 if (this.pendingTimer) clearTimeout(this.pendingTimer);249 if (this.pendingTimer) clearTimeout(this.pendingTimer);
...@@ -441,11 +438,9 @@ class Observer<Args extends unknown[], Result> {...@@ -441,11 +438,9 @@ class Observer<Args extends unknown[], Result> {
441 return self.state.isMutating;438 return self.state.isMutating;
442 },439 },
443 get isAllowed() {440 get isAllowed() {
444 self.watched.add("isAllowed");
445 return self.mutation?.isAllowed() ?? false;441 return self.mutation?.isAllowed() ?? false;
446 },442 },
447 get isDisabled() {443 get isDisabled() {
448 self.watched.add("isDisabled");
449 self.watched.add("isMutating");444 self.watched.add("isMutating");
450 const mutation = self.mutation;445 const mutation = self.mutation;
451 if (!mutation || (self.state.isMutating && !self.debounced)) return true;446 if (!mutation || (self.state.isMutating && !self.debounced)) return true;
src/types.ts+42-4
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import type { MutationClient } from "./client.ts";1import type { MutationClient } from "./client.ts";
22
3/**3/**
4 * Mutation arguments must be JSON serializable so a call can be stored and4 * Plain JSON value. The default serializable leaf set, still exported for
5 * replayed later, such as re-running a mutation after the user signs in.5 * consumers that want the exact JSON shape.
6 */6 */
7export type Json =7export type Json =
8 | string8 | string
...@@ -12,6 +12,44 @@ export type Json =...@@ -12,6 +12,44 @@ export type Json =
12 | Json[]12 | Json[]
13 | { [key: string]: Json };13 | { [key: string]: Json };
1414
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 */
26export interface SerializableLeaves {}
27
28type 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 */
38export 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 */
48export type SerializableValue =
49 | Leaf
50 | SerializableValue[]
51 | { [key: string]: SerializableValue };
52
15/**53/**
16 * A serializable record of a mutation call. Produced when an authenticated54 * A serializable record of a mutation call. Produced when an authenticated
17 * mutation is attempted without a user; pass it to `MutationClient.run`55 * mutation is attempted without a user; pass it to `MutationClient.run`
...@@ -20,7 +58,7 @@ export type Json =...@@ -20,7 +58,7 @@ export type Json =
20export interface MutationAction {58export interface MutationAction {
21 id: string;59 id: string;
22 description: string;60 description: string;
23 args: Json[];61 args: SerializableValue[];
24}62}
2563
26export interface Mutation<Args extends unknown[], Result> {64export interface Mutation<Args extends unknown[], Result> {
...@@ -84,7 +122,7 @@ export interface RunOptions<Result> {...@@ -84,7 +122,7 @@ export interface RunOptions<Result> {
84 * available. The action is JSON serializable and can be passed to122 * available. The action is JSON serializable and can be passed to
85 * `MutationClient.run` after sign-in.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}
89127
90export interface MutationEvent<Result> {128export interface MutationEvent<Result> {
test/auth.test.tsx+49
...@@ -566,3 +566,52 @@ test("authenticated mutation isAllowed receives user context", () => {...@@ -566,3 +566,52 @@ test("authenticated mutation isAllowed receives user context", () => {
566 currentUser = null;566 currentUser = null;
567 assertEquals(mutation.isAllowed(), false);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
574function _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.
613declare module "../src/types.ts" {
614 interface SerializableLeaves {
615 file: File;
616 }
617}
test/useMutate.test.tsx+67-1
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { assertEquals } from "@std/assert";1import { assertEquals } from "@std/assert";
2import { act, render, screen } from "@testing-library/react";2import { act, render, screen } from "@testing-library/react";
3import { userEvent } from "@testing-library/user-event";3import { userEvent } from "@testing-library/user-event";
4import type { FC, MouseEventHandler, ReactNode } from "react";4import { type FC, type MouseEventHandler, type ReactNode, StrictMode } from "react";
5import { renderToString } from "react-dom/server";
5import { test, vi } from "vitest";6import { test, vi } from "vitest";
7import { MutationClient } from "../src/client.ts";
6import { createMutationButton, useMutate } from "../src/react.ts";8import { createMutationButton, useMutate } from "../src/react.ts";
7import { createTestMutationClient, IterableStream } from "./share.ts";9import { createTestMutationClient, IterableStream } from "./share.ts";
810
...@@ -321,3 +323,67 @@ test("MutationButton should allow args={null} to disable mutation runs", async (...@@ -321,3 +323,67 @@ test("MutationButton should allow args={null} to disable mutation runs", async (
321323
322 assertEquals(mutate.mock.calls, []);324 assertEquals(mutate.mock.calls, []);
323});325});
326
327function 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
360test("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
370test("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});