authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-27 18:21:53-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-27 18:45:36-08:00
logb222f6adf6944284280e7ff42bdb6bb2242bce84
tree2faf8bcca29fd7375c171ef2d6961facc0996a68
parentfd7e4484d26ac5e095cb39bfb2bfc17fe6527d43
signaturelock-open Commit is signed but in an unrecognized format.

feat: prepare beta 1 for jsr


13 files changed, 630 insertions(+), 440 deletions(-)

LICENSE created+7
......@@ -0,0 +1,7 @@
1ISC License
2
3Copyright 2026 clover caruso
4
5Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
7THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
example/tsconfig.json+1-1
......@@ -17,7 +17,7 @@
1717 "allowImportingTsExtensions": true,
1818 "jsx": "react-jsx",
1919 "paths": {
20 "@clo/react-mutation": ["../src/index.ts"]
20 "@clo/react-mutation": ["../src/mod.ts"]
2121 }
2222 },
2323 "include": ["src/**/*"],
jsr.json created+22
......@@ -0,0 +1,22 @@
1{
2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.1",
4 "exports": {
5 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",
7 "./object-path.ts": "./src/object-path.ts"
8 },
9 "imports": {
10 "@tanstack/react-query": "npm:@tanstack/react-query@^5",
11 "react": "npm:react@^19"
12 },
13 "publish": {
14 "include": [
15 "LICENSE",
16 "README.md",
17 "src/**/*",
18 "test/**/*"
19 ]
20 },
21 "license": "ISC"
22}
package.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "0.0.0",
3 "version": "0.1.0",
44 "private": true,
55 "description": "",
66 "license": "ISC",
readme.md+43-15
......@@ -40,7 +40,8 @@ export const mutations = new MutationClient({
4040 // All properties in `context` are available within mutation functions.
4141 context: {
4242 client: queryClient,
43 // Can add easy helpers for your codebase.
43 // Can add any easy helpers for your codebase.
44 // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet`)
4445 get: (k: QueryKey) => client.getQueryData(k),
4546 },
4647
......@@ -61,17 +62,11 @@ export const mutations = new MutationClient({
6162
6263### Queued Mutations
6364
64A queued mutation is defined with `mutations.defineQueued`. These are useful for creating
65A queued mutation is defined with `mutations.defineQueued`.
6566
6667```tsx
67const queryCounter = queryOptions({
68 queryKey: ["items"],
69 queryFn: (): Promise<string[]> => fetch(),
70});
71const queryItem = (id: string) => queryOptions({
72 queryKey: ["items", id],
73 queryFn: (): Promise<object> => fetch(),
74});
68const queryItemList = queryOptions({ ... });
69const queryItem = (id: string) => queryOptions({ ... });
7570
7671const mutDeleteItem = mutations.defineQueued({
7772 // `mutate` comes first, is only worried about syncing with the backend.
......@@ -80,13 +75,13 @@ const mutDeleteItem = mutations.defineQueued({
8075 if (!response.ok) throw new Error(`HTTP ${response.status}`);
8176 },
8277
83 optimistic({ helpers, args: [amount] }) {
84 helpers.removeFromArray(queryItemList, ".", (n) => );
85 helpers.removeQuery(queryItem)
78 optimistic({ client, get, helpers, args: [id] }) {
79 helpers.arrayRemove(queryItemList, (item) => item === id);
80 helpers.removeQuery(queryItem);
8681 },
8782
88 describe({ client, args: [id] }) {
89 const { title } = client.getQueryData<object>(queryItem().queryKey);
83 describe({ get, args: [id] }) {
84 const title = get(queryItem().queryKey)?.title ?? "Unknown Item";
9085 return `delete '${title}'`;
9186 },
9287
......@@ -105,3 +100,36 @@ export function Example({ id }: { id: string }) {
105100 </li>);
106101}
107102```
103
104### Batched Mutations
105
106A batched mutation is defined with `mutations.defineBatched`.
107
108```tsx
109const mutSetItemName = mutationClient.defineBatched({
110 mode: "debounce",
111 time: 200,
112
113 // start by mutating the optimistic state
114 optimistic({ helpers }, id: string, name: string) {
115 helpers.objSet(queryItem(id), ["title"], name);
116 },
117 // a value is snapshot before calling `optimistic` and after the
118 // timer. if the snapshots differ, the `commit` function is called.
119 getValue: ({ get }) => get(queryCounter)?.title ?? "",
120
121 // batch the same `id`s together
122 key: ({ args: [id] }) => id,
123
124 // commit the result to the backend
125 async commit({ initial, current, args: [id] }) {
126 const response = await fetch(`/items/${id}`, {
127 method: "patch",
128 body: JSON.stringify({ title: current }),
129 });
130 if (!response.ok) throw new Error(`HTTP ${response.status}`);
131 },
132
133 describe: ({ get }) => `rename '${get(queryItem())?.title ?? 'unknown'}'`,
134});
135```
src/batch.ts+3-3
......@@ -39,7 +39,7 @@ export interface BatchMutationOptions<
3939 * Commit the optimistic state. Throw on failure.
4040 */
4141 commit: (
42 context: BatchMutatorArgs<NoInfer<Args>, Optimistic, Config>,
42 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,
4343 ) => Promise<Result>;
4444 /**
4545 * Used in error messages and debug tools.
......@@ -48,7 +48,7 @@ export interface BatchMutationOptions<
4848 describe:
4949 | string
5050 | ((
51 context: BatchMutatorArgs<NoInfer<Args>, Optimistic, Config>,
51 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,
5252 ) => string);
5353 /**
5454 * Refetch all of the data this mutation could have affected.
......@@ -64,7 +64,7 @@ export type BatchOptimisticContext<Config extends MutationClientConfig> =
6464 helpers: Config["optimisticHelpers"];
6565 };
6666
67export type BatchMutatorArgs<
67export type BatchCommitContext<
6868 Args,
6969 Optimistic,
7070 Config extends MutationClientConfig,
src/index.ts deleted-17
......@@ -1,17 +0,0 @@
1export { type MutationOptions, type OptimisticContext } from "./queued.ts";
2export {
3 MutationClient,
4 type MutationClientFromConfig,
5 type MutationClientOptions,
6} from "./client.ts";
7export {
8 createMutationButton,
9 type MutationButtonProps,
10 useMutation,
11 type UseMutationError,
12 type UseMutationIdle,
13 type UseMutationResult,
14 type UseMutationResultBase,
15 type UseMutationSuccess,
16} from "./react.tsx";
17export { queryClientOptimisticHelpers } from "./tanstack-query.ts";
src/mod.ts created+23
......@@ -0,0 +1,23 @@
1export type { MutationOptions, OptimisticContext } from "./queued.ts";
2export type {
3 BatchCommitContext,
4 BatchMutationOptions,
5 BatchOptimisticContext,
6} from "./batch.ts";
7export {
8 MutationClient,
9 type MutationClientConfig,
10 type MutationClientFromConfig,
11 type MutationClientOptions,
12} from "./client.ts";
13export type { Mutation, MutationEvent } from "./types.ts";
14export {
15 createMutationButton,
16 type MutationButtonProps,
17 useMutation,
18 type UseMutationError,
19 type UseMutationIdle,
20 type UseMutationResult,
21 type UseMutationResultBase,
22 type UseMutationSuccess,
23} from "./react.tsx";
src/object-path.ts-2
......@@ -1,5 +1,3 @@
1import { assert } from "@std/assert";
2
31export type AllObjectPaths<T, Filter = unknown> = T extends
42 ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
53 : T extends object ?
src/tanstack-query.ts+513-381
......@@ -1,403 +1,535 @@
1import {
1import type {
22 QueryClient,
33 QueryFunction,
4 QueryFunctionContext,
54 QueryKey,
6 QueryOptions,
7 SkipToken,
85 Updater,
9 UseQueryOptions,
106} from "@tanstack/react-query";
117import {
128 type AllObjectPaths,
139 type GetObjectPath,
14 getPath as getPath,
15 setPath as setPath,
10 getPath,
11 setPath,
1612} from "./object-path.ts";
17import { OptimisticEvents } from "./client.ts";
13import type { OptimisticEvents } from "./client.ts";
1814
19export type QueryKeyAndFn<T = unknown> = {
20 queryKey: QueryKey;
15export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
16 queryKey: Key;
2117 queryFn?: QueryFunction<T, any, never> | undefined;
2218};
2319
24export function queryClientOptimisticHelpers(client: QueryClient) {
25 return ({ onRefetch, onRestore }: OptimisticEvents) => {
26 const refetchHashes: string[] = [];
20export function boundQueryClientGet(
21 queryClient: QueryClient,
22): <T>({ queryKey }: QueryKeyAndFn<T>) => T | undefined {
23 return function get<T>({ queryKey }: QueryKeyAndFn<T>) {
24 return queryClient.getQueryData<T>(queryKey);
25 };
26}
27
28class TanstackQueryOptimisticHelpers {
29 #client: QueryClient;
30 #onRefetch: OptimisticEvents["onRefetch"];
31 #onRestore: OptimisticEvents["onRestore"];
32 #refetchHashes: string[] = [];
33
34 constructor(client: QueryClient, { onRefetch, onRestore }: OptimisticEvents) {
35 this.#client = client;
36 this.#onRefetch = onRefetch;
37 this.#onRestore = onRestore;
38 }
2739
28 function get<T>({ queryKey }: QueryKeyAndFn<T>) {
29 return client.getQueryData<T>(queryKey);
40 #get<T>({ queryKey }: QueryKeyAndFn<T>) {
41 return this.#client.getQueryData<T>(queryKey);
42 }
43
44 #set<T>(
45 query: QueryKeyAndFn<T>,
46 updater: Updater<NoInfer<T> | undefined, NoInfer<T> | undefined>,
47 ) {
48 const result = this.#client.setQueryData<T>(query.queryKey, updater);
49 if (result) {
50 this.#client.cancelQueries({ queryKey: query.queryKey, exact: true })
51 .catch(() => {});
52 const state = this.#client.getQueryCache().find({
53 queryKey: query.queryKey,
54 exact: true,
55 });
56 if (!state || this.#refetchHashes.includes(state.queryHash)) return;
57 this.#refetchHashes.push(state.queryHash);
58 try {
59 this.#onRefetch(async () => {
60 await this.#client.refetchQueries(query);
61 });
62 } catch { /* Ignore expiry */ }
3063 }
31 function set<T>(
32 query: QueryKeyAndFn<T>,
33 updater: Updater<NoInfer<T> | undefined, NoInfer<T> | undefined>,
34 ) {
35 const result = client.setQueryData<T>(query.queryKey, updater);
36 if (result) {
37 client.cancelQueries({ queryKey: query.queryKey, exact: true })
38 .catch(() => {});
39 const state = client.getQueryCache().find({
40 queryKey: query.queryKey,
64 }
65
66 /**
67 * Set the entire query data.
68 * If the query doesn't exist, the new query is created.
69 */
70 set<Data>(
71 queryKey: QueryKeyAndFn<Data>,
72 value: Data | ((prev: Data | undefined) => Data | undefined),
73 ) {
74 const prev = this.#get(queryKey);
75
76 const newValue = typeof value === "function"
77 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
78 : value;
79
80 this.#set(queryKey, newValue);
81 this.#onRestore(() => {
82 if (prev === undefined) {
83 this.#client.removeQueries({
84 queryKey: queryKey.queryKey,
4185 exact: true,
4286 });
43 if (!state || refetchHashes.includes(state.queryHash)) return;
44 refetchHashes.push(state.queryHash);
45 onRefetch(async () => {
46 await client.refetchQueries(query);
47 });
87 } else {
88 this.#set(queryKey, prev);
4889 }
90 });
91 }
92
93 /**
94 * Update the entire query data.
95 * If the query doesn't exist, it cancels
96 */
97 updateExisting<Data>(
98 queryKey: QueryKeyAndFn<Data>,
99 value: Data | ((prev: Data) => Data),
100 ) {
101 const prev = this.#get(queryKey);
102 if (!prev) return;
103
104 const newValue = typeof value === "function"
105 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
106 : value;
107
108 this.#set(queryKey, newValue);
109 this.#onRestore(() => {
110 this.#set(queryKey, prev);
111 });
112 }
113
114 /**
115 * Set a property at an object path.
116 * If the query or path doesn't exist, the updater is skipped.
117 */
118 objSet<Data extends object, const Path extends AllObjectPaths<Data>>(
119 queryKey: QueryKeyAndFn<Data>,
120 path: Path,
121 value:
122 | Exclude<GetObjectPath<Data, Path>, Function>
123 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
124 ) {
125 const prev = this.#get(queryKey);
126 if (!prev) return;
127 const { value: original, exists } = getPath(prev, path);
128 if (!exists) return;
129
130 this.#set(
131 queryKey,
132 (obj) =>
133 obj
134 ? setPath(
135 obj,
136 path,
137 typeof value === "function"
138 ? (value as ((
139 prev: GetObjectPath<Data, Path>,
140 ) => GetObjectPath<Data, Path>))(original)
141 : value,
142 )
143 : obj,
144 );
145 this.#onRestore(() => {
146 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
147 });
148 }
149
150 /**
151 * Increment a numeric property at an object path.
152 * If the query or path doesn't exist, the updater is skipped.
153 */
154 objIncrement<
155 Data extends object,
156 const Path extends AllObjectPaths<Data>,
157 >(
158 queryKey: QueryKeyAndFn<Data>,
159 path: Path,
160 amount: number = 1,
161 ) {
162 const prev = this.#get(queryKey);
163 if (!prev) return;
164 const { value: original, exists } = getPath(prev, path);
165 if (!exists || typeof original !== "number") return;
166
167 this.#set(
168 queryKey,
169 (obj) => obj ? setPath(obj, path, (original + amount) as any) : obj,
170 );
171 this.#onRestore(() => {
172 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
173 });
174 }
175
176 /**
177 * Decrement a numeric property at an object path.
178 * If the query or path doesn't exist, the updater is skipped.
179 */
180 objDecrement<
181 Data extends object,
182 const Path extends AllObjectPaths<Data>,
183 >(
184 queryKey: QueryKeyAndFn<Data>,
185 path: Path,
186 amount: number = 1,
187 ) {
188 const prev = this.#get(queryKey);
189 if (!prev) return;
190 const { value: original, exists } = getPath(prev, path);
191 if (!exists || typeof original !== "number") return;
192
193 this.#set(
194 queryKey,
195 (obj) => obj ? setPath(obj, path, (original - amount) as any) : obj,
196 );
197 this.#onRestore(() => {
198 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
199 });
200 }
201
202 /**
203 * Toggle a boolean property at an object path.
204 * If the query or path doesn't exist, the updater is skipped.
205 */
206 objToggle<Data extends object, const Path extends AllObjectPaths<Data>>(
207 queryKey: QueryKeyAndFn<Data>,
208 path: Path,
209 ) {
210 const prev = this.#get(queryKey);
211 if (!prev) return;
212 const { value: original, exists } = getPath(prev, path);
213 if (!exists || typeof original !== "boolean") return;
214
215 this.#set(
216 queryKey,
217 (obj) => obj ? setPath(obj, path, (!original) as any) : obj,
218 );
219 this.#onRestore(() => {
220 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
221 });
222 }
223
224 /**
225 * Shallow merge multiple properties at an object path.
226 * If the query or path doesn't exist, the updater is skipped.
227 */
228 objSetMany<Data extends object, const Path extends AllObjectPaths<Data>>(
229 queryKey: QueryKeyAndFn<Data>,
230 path: Path,
231 updates: Partial<GetObjectPath<Data, Path>>,
232 ) {
233 const prev = this.#get(queryKey);
234 if (!prev) return;
235 const { value: original, exists } = getPath(prev, path);
236 if (!exists || typeof original !== "object" || original === null) {
237 return;
49238 }
50239
51 return ({
52 /**
53 * Set the entire query data.
54 * If the query doesn't exist, the new query is created.
55 */
56 set<Data>(
57 queryKey: QueryKeyAndFn<Data>,
58 value: Data | ((prev: Data | undefined) => Data | undefined),
59 ) {
60 const prev = get(queryKey);
61
62 const newValue = typeof value === "function"
63 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
64 : value;
65
66 set(queryKey, newValue);
67 onRestore(() => {
68 if (prev === undefined) {
69 client.removeQueries({ queryKey: queryKey.queryKey, exact: true });
70 } else {
71 set(queryKey, prev);
72 }
73 });
74 },
75
76 /**
77 * Update the entire query data.
78 * If the query doesn't exist, it cancels
79 */
80 updateExisting<Data>(
81 queryKey: QueryKeyAndFn<Data>,
82 value: Data | ((prev: Data) => Data),
83 ) {
84 const prev = get(queryKey);
85 if (!prev) return false;
86
87 const newValue = typeof value === "function"
88 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
89 : value;
90
91 set(queryKey, newValue);
92 onRestore(() => {
93 set(queryKey, prev);
94 });
95 },
96
97 /**
98 * Set a property at an object path.
99 * If the query or path doesn't exist, the updater is skipped.
100 */
101 objSet<Data extends object, const Path extends AllObjectPaths<Data>>(
102 queryKey: QueryKeyAndFn<Data>,
103 path: Path,
104 value:
105 | Exclude<GetObjectPath<Data, Path>, Function>
106 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
107 ) {
108 const prev = get(queryKey);
109 if (!prev) return;
110 const { value: original, exists } = getPath(prev, path);
111 if (!exists) return;
112
113 set(
114 queryKey,
115 (obj) =>
116 obj
117 ? setPath(
118 obj,
119 path,
120 typeof value === "function"
121 ? (value as ((
122 prev: GetObjectPath<Data, Path>,
123 ) => GetObjectPath<Data, Path>))(original)
124 : value,
125 )
126 : obj,
127 );
128 onRestore(() => {
129 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
130 });
131 },
132
133 /**
134 * Increment a numeric property at an object path.
135 * If the query or path doesn't exist, the updater is skipped.
136 */
137 objIncrement<
138 Data extends object,
139 const Path extends AllObjectPaths<Data>,
140 >(
141 queryKey: QueryKeyAndFn<Data>,
142 path: Path,
143 amount: number = 1,
144 ) {
145 const prev = get(queryKey);
146 if (!prev) return;
147 const { value: original, exists } = getPath(prev, path);
148 if (!exists || typeof original !== "number") return;
149
150 set(
151 queryKey,
152 (obj) => obj ? setPath(obj, path, (original + amount) as any) : obj,
153 );
154 onRestore(() => {
155 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
156 });
157 },
158
159 /**
160 * Decrement a numeric property at an object path.
161 * If the query or path doesn't exist, the updater is skipped.
162 */
163 objDecrement<
164 Data extends object,
165 const Path extends AllObjectPaths<Data>,
166 >(
167 queryKey: QueryKeyAndFn<Data>,
168 path: Path,
169 amount: number = 1,
170 ) {
171 const prev = get(queryKey);
172 if (!prev) return;
173 const { value: original, exists } = getPath(prev, path);
174 if (!exists || typeof original !== "number") return;
175
176 set(
177 queryKey,
178 (obj) => obj ? setPath(obj, path, (original - amount) as any) : obj,
179 );
180 onRestore(() => {
181 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
182 });
183 },
184
185 /**
186 * Toggle a boolean property at an object path.
187 * If the query or path doesn't exist, the updater is skipped.
188 */
189 objToggle<Data extends object, const Path extends AllObjectPaths<Data>>(
190 queryKey: QueryKeyAndFn<Data>,
191 path: Path,
192 ) {
193 const prev = get(queryKey);
194 if (!prev) return;
195 const { value: original, exists } = getPath(prev, path);
196 if (!exists || typeof original !== "boolean") return;
197
198 set(
199 queryKey,
200 (obj) => obj ? setPath(obj, path, (!original) as any) : obj,
201 );
202 onRestore(() => {
203 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
204 });
205 },
206
207 /**
208 * Shallow merge multiple properties at an object path.
209 * If the query or path doesn't exist, the updater is skipped.
210 */
211 objSetMany<Data extends object, const Path extends AllObjectPaths<Data>>(
212 queryKey: QueryKeyAndFn<Data>,
213 path: Path,
214 updates: Partial<GetObjectPath<Data, Path>>,
215 ) {
216 const prev = get(queryKey);
217 if (!prev) return;
218 const { value: original, exists } = getPath(prev, path);
219 if (!exists || typeof original !== "object" || original === null) {
220 return;
221 }
222
223 const merged = { ...original as object, ...updates } as GetObjectPath<
224 Data,
225 Path
226 >;
227 set(
228 queryKey,
229 (obj) => obj ? setPath(obj, path, merged) : obj,
230 );
231 onRestore(() => {
232 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
233 });
234 },
235
236 /**
237 * Push item(s) to the end of an array at an object path.
238 * If the query or path doesn't exist, the updater is skipped.
239 */
240 arrayPush<Data extends object, const Path extends AllObjectPaths<Data>>(
241 queryKey: QueryKeyAndFn<Data>,
242 path: Path,
243 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
244 : never
245 ) {
246 const prev = get(queryKey);
247 if (!prev) return;
248 const { value: original, exists } = getPath(prev, path);
249 if (!exists || !Array.isArray(original)) return;
250
251 const newArray = [...original, ...items];
252 set(
253 queryKey,
254 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
255 );
256 onRestore(() => {
257 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
258 });
259 },
260
261 /**
262 * Add item(s) to the beginning of an array at an object path.
263 * If the query or path doesn't exist, the updater is skipped.
264 */
265 arrayUnshift<
266 Data extends object,
267 const Path extends AllObjectPaths<Data>,
268 >(
269 queryKey: QueryKeyAndFn<Data>,
270 path: Path,
271 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
272 : never
273 ) {
274 const prev = get(queryKey);
275 if (!prev) return;
276 const { value: original, exists } = getPath(prev, path);
277 if (!exists || !Array.isArray(original)) return;
278
279 const newArray = [...items, ...original];
280 set(
281 queryKey,
282 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
283 );
284 onRestore(() => {
285 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
286 });
287 },
288
289 /**
290 * Remove items from an array that match a predicate.
291 * If the query or path doesn't exist, the updater is skipped.
292 */
293 arrayRemoveItem<
294 Data extends object,
295 const Path extends AllObjectPaths<Data>,
296 >(
297 queryKey: QueryKeyAndFn<Data>,
298 path: Path,
299 predicate: (
300 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
301 index: number,
302 ) => boolean,
303 ) {
304 const prev = get(queryKey);
305 if (!prev) return;
306 const { value: original, exists } = getPath(prev, path);
307 if (!exists || !Array.isArray(original)) return;
308
309 const newArray = original.filter((item, index) =>
310 !predicate(item, index)
311 );
312 set(
313 queryKey,
314 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
315 );
316 onRestore(() => {
317 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
318 });
319 },
320
321 /**
322 * Update items in an array that match a predicate.
323 * If the query or path doesn't exist, the updater is skipped.
324 */
325 arrayUpdateItem<
326 Data extends object,
327 const Path extends AllObjectPaths<Data>,
328 >(
329 queryKey: QueryKeyAndFn<Data>,
330 path: Path,
331 predicate: (
332 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
333 index: number,
334 ) => boolean,
335 updater: (
336 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
337 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
338 ) {
339 const prev = get(queryKey);
340 if (!prev) return;
341 const { value: original, exists } = getPath(prev, path);
342 if (!exists || !Array.isArray(original)) return;
343
344 const newArray = original.map((item, index) =>
345 predicate(item, index) ? updater(item) : item
346 );
347 set(
348 queryKey,
349 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
350 );
351 onRestore(() => {
352 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
353 });
354 },
355
356 /**
357 * Insert item(s) at a specific index in an array.
358 * If the query or path doesn't exist, the updater is skipped.
359 */
360 arrayInsertIndex<
361 Data extends object,
362 const Path extends AllObjectPaths<Data>,
363 >(
364 queryKey: QueryKeyAndFn<Data>,
365 path: Path,
366 index: number,
367 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
368 : never
369 ) {
370 const prev = get(queryKey);
371 if (!prev) return;
372 const { value: original, exists } = getPath(prev, path);
373 if (!exists || !Array.isArray(original)) return;
374
375 const newArray = [
376 ...original.slice(0, index),
377 ...items,
378 ...original.slice(index),
379 ];
380 set(
381 queryKey,
382 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
383 );
384 onRestore(() => {
385 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
386 });
387 },
388
389 /**
390 * Remove a query from the cache entirely.
391 */
392 removeQuery(queryKey: QueryKeyAndFn) {
393 const prev = get(queryKey);
394 if (!prev) return;
395
396 client.removeQueries({ queryKey: queryKey.queryKey, exact: true });
397 onRestore(() => {
398 set(queryKey, prev);
399 });
400 },
240 const merged = { ...original as object, ...updates } as GetObjectPath<
241 Data,
242 Path
243 >;
244 this.#set(
245 queryKey,
246 (obj) => obj ? setPath(obj, path, merged) : obj,
247 );
248 this.#onRestore(() => {
249 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
401250 });
402 };
251 }
252
253 /**
254 * Push item(s) to the end of an array at an object path.
255 * If the query or path doesn't exist, the updater is skipped.
256 */
257 objArrayPush<
258 Data extends object,
259 const Path extends AllObjectPaths<Data>,
260 >(
261 queryKey: QueryKeyAndFn<Data>,
262 path: Path,
263 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
264 : never
265 ) {
266 const prev = this.#get(queryKey);
267 if (!prev) return;
268 const { value: original, exists } = getPath(prev, path);
269 if (!exists || !Array.isArray(original)) return;
270
271 const newArray = [...original, ...items];
272 this.#set(
273 queryKey,
274 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
275 );
276 this.#onRestore(() => {
277 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
278 });
279 }
280
281 /**
282 * Add item(s) to the beginning of an array at an object path.
283 * If the query or path doesn't exist, the updater is skipped.
284 */
285 objArrayUnshift<
286 Data extends object,
287 const Path extends AllObjectPaths<Data>,
288 >(
289 queryKey: QueryKeyAndFn<Data>,
290 path: Path,
291 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
292 : never
293 ) {
294 const prev = this.#get(queryKey);
295 if (!prev) return;
296 const { value: original, exists } = getPath(prev, path);
297 if (!exists || !Array.isArray(original)) return;
298
299 const newArray = [...items, ...original];
300 this.#set(
301 queryKey,
302 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
303 );
304 this.#onRestore(() => {
305 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
306 });
307 }
308
309 /**
310 * Remove items from an array that match a predicate.
311 * If the query or path doesn't exist, the updater is skipped.
312 */
313 objArrayRemove<
314 Data extends object,
315 const Path extends AllObjectPaths<Data>,
316 >(
317 queryKey: QueryKeyAndFn<Data>,
318 path: Path,
319 predicate: (
320 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
321 index: number,
322 ) => boolean,
323 ) {
324 const prev = this.#get(queryKey);
325 if (!prev) return;
326 const { value: original, exists } = getPath(prev, path);
327 if (!exists || !Array.isArray(original)) return;
328
329 const newArray = original.filter((item, index) => !predicate(item, index));
330 this.#set(
331 queryKey,
332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
333 );
334 this.#onRestore(() => {
335 // TODO: splice items back in case original changed
336 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
337 });
338 }
339
340 /**
341 * Update items in an array that match a predicate.
342 * If the query or path doesn't exist, the updater is skipped.
343 */
344 objArrayUpdate<
345 Data extends object,
346 const Path extends AllObjectPaths<Data>,
347 >(
348 queryKey: QueryKeyAndFn<Data>,
349 path: Path,
350 predicate: (
351 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
352 index: number,
353 ) => boolean,
354 updater: (
355 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
356 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
357 ) {
358 const prev = this.#get(queryKey);
359 if (!prev) return;
360 const { value: original, exists } = getPath(prev, path);
361 if (!exists || !Array.isArray(original)) return;
362
363 const newArray = original.map((item, index) =>
364 predicate(item, index) ? updater(item) : item
365 );
366 this.#set(
367 queryKey,
368 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
369 );
370 this.#onRestore(() => {
371 // TODO: splice items back in case original changed
372 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
373 });
374 }
375
376 /**
377 * Insert item(s) at a specific index in an array.
378 * If the query or path doesn't exist, the updater is skipped.
379 */
380 objArrayInsertIndex<
381 Data extends object,
382 const Path extends AllObjectPaths<Data>,
383 >(
384 queryKey: QueryKeyAndFn<Data>,
385 path: Path,
386 index: number,
387 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
388 : never
389 ) {
390 const prev = this.#get(queryKey);
391 if (!prev) return;
392 const { value: original, exists } = getPath(prev, path);
393 if (!exists || !Array.isArray(original)) return;
394
395 const newArray = [
396 ...original.slice(0, index),
397 ...items,
398 ...original.slice(index),
399 ];
400 this.#set(
401 queryKey,
402 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
403 );
404 this.#onRestore(() => {
405 // TODO: splice items back in case original changed
406 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
407 });
408 }
409
410 /**
411 * Push item(s) to the end of an array at an object path.
412 * If the query or path doesn't exist, the updater is skipped.
413 */
414 arrayPush<Data>(queryKey: QueryKeyAndFn<Data[]>, ...items: Data[]) {
415 const prev = this.#get(queryKey);
416 if (!prev || !Array.isArray(prev)) return;
417
418 const newArray = [...prev, ...items];
419 this.#set(queryKey, newArray);
420 this.#onRestore(() => {
421 this.#set(
422 queryKey,
423 (old) => old ? old.filter((x) => !items.includes(x)) : old,
424 );
425 });
426 }
427
428 /**
429 * Add item(s) to the beginning of an array at an object path.
430 * If the query or path doesn't exist, the updater is skipped.
431 */
432 arrayUnshift<Data>(queryKey: QueryKeyAndFn<Data[]>, ...items: Data[]) {
433 const prev = this.#get(queryKey);
434 if (!prev || !Array.isArray(prev)) return;
435
436 const newArray = [...items, ...prev];
437 this.#set(queryKey, newArray);
438 this.#onRestore(() => {
439 this.#set(
440 queryKey,
441 (old) => old ? old.filter((x) => !items.includes(x)) : old,
442 );
443 });
444 }
445
446 /**
447 * Remove items from an array that match a predicate.
448 * If the query or path doesn't exist, the updater is skipped.
449 */
450 arrayRemove<Data>(
451 queryKey: QueryKeyAndFn<Data[]>,
452 predicate: (
453 item: Data,
454 index: number,
455 ) => boolean,
456 ) {
457 const prev = this.#get(queryKey);
458 if (!prev || !Array.isArray(prev)) return;
459
460 const newArray = prev.filter((item, index) => !predicate(item, index));
461 this.#set(queryKey, newArray);
462 this.#onRestore(() => {
463 // TODO: splice items back in case original changed
464 this.#set(queryKey, prev);
465 });
466 }
467
468 /**
469 * Update items in an array that match a predicate.
470 * If the query or path doesn't exist, the updater is skipped.
471 */
472 arrayUpdate<Data>(
473 queryKey: QueryKeyAndFn<Data[]>,
474 predicate: (
475 item: Data,
476 index: number,
477 ) => boolean,
478 updater: (item: Data) => Data,
479 ) {
480 const prev = this.#get(queryKey);
481 if (!prev || !Array.isArray(prev)) return;
482
483 const newArray = prev.map((item, index) =>
484 predicate(item, index) ? updater(item) : item
485 );
486 this.#set(queryKey, newArray);
487 this.#onRestore(() => {
488 // TODO: splice items back in case original changed
489 this.#set(queryKey, prev);
490 });
491 }
492
493 /**
494 * Insert item(s) at a specific index in an array.
495 * If the query or path doesn't exist, the updater is skipped.
496 */
497 arrayInsertIndex<Data>(
498 queryKey: QueryKeyAndFn<Data[]>,
499 index: number,
500 ...items: Data[]
501 ) {
502 const prev = this.#get(queryKey);
503 if (!prev || !Array.isArray(prev)) return;
504
505 const newArray = [
506 ...prev.slice(0, index),
507 ...items,
508 ...prev.slice(index),
509 ];
510 this.#set(queryKey, newArray);
511 this.#onRestore(() => {
512 // TODO: splice items back in case original changed
513 this.#set(queryKey, prev);
514 });
515 }
516
517 /**
518 * Remove a query from the cache entirely.
519 */
520 removeQuery(queryKey: QueryKeyAndFn) {
521 const prev = this.#get(queryKey);
522 if (!prev) return;
523
524 this.#client.removeQueries({ queryKey: queryKey.queryKey, exact: true });
525 this.#onRestore(() => {
526 this.#set(queryKey, prev);
527 });
528 }
529}
530
531export function queryClientOptimisticHelpers(
532 client: QueryClient,
533): (e: OptimisticEvents) => TanstackQueryOptimisticHelpers {
534 return (events) => new TanstackQueryOptimisticHelpers(client, events);
403535}
test/batch.test.ts-1
......@@ -2,7 +2,6 @@ import { assertEquals, assertRejects } from "@std/assert";
22import { MutationClient } from "../src/client.ts";
33import type { MutationEvent } from "../src/types.ts";
44import { test } from "vitest";
5import { UIEventHandler } from "react";
65
76// Shared test store for optimistic updates
87const testStore = new Map<string, number>();
test/tanstack-query-helpers.test.ts+15-15
......@@ -440,7 +440,7 @@ test("arrayPush - should add items to end of array", () => {
440440 onRefetch: () => {},
441441 });
442442
443 helpers.arrayPush(
443 helpers.objArrayPush(
444444 queryTest,
445445 ["items"],
446446 { id: 4, label: "fourth" },
......@@ -466,7 +466,7 @@ test("arrayPush - should work with simple arrays", () => {
466466 onRefetch: () => {},
467467 });
468468
469 helpers.arrayPush(queryTest, ["tags"], "delta", "epsilon");
469 helpers.objArrayPush(queryTest, ["tags"], "delta", "epsilon");
470470
471471 const result = client.getQueryData<TestData>(queryTest.queryKey);
472472 assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta", "epsilon"]);
......@@ -485,7 +485,7 @@ test("arrayUnshift - should add items to beginning of array", () => {
485485 onRefetch: () => {},
486486 });
487487
488 helpers.arrayUnshift(
488 helpers.objArrayUnshift(
489489 queryTest,
490490 ["items"],
491491 { id: 0, label: "zeroth" },
......@@ -516,7 +516,7 @@ test("arrayRemoveItem - should remove items matching predicate", () => {
516516 onRefetch: () => {},
517517 });
518518
519 helpers.arrayRemoveItem(
519 helpers.objArrayRemove(
520520 queryTest,
521521 ["items"],
522522 (item) => item.id === 2,
......@@ -541,7 +541,7 @@ test("arrayRemoveItem - should remove multiple items", () => {
541541 onRefetch: () => {},
542542 });
543543
544 helpers.arrayRemoveItem(
544 helpers.objArrayRemove(
545545 queryTest,
546546 ["items"],
547547 (item) => item.id > 1,
......@@ -560,7 +560,7 @@ test("arrayRemoveItem - should work with simple arrays", () => {
560560 onRefetch: () => {},
561561 });
562562
563 helpers.arrayRemoveItem(
563 helpers.objArrayRemove(
564564 queryTest,
565565 ["tags"],
566566 (tag) => tag === "beta",
......@@ -583,7 +583,7 @@ test("arrayUpdateItem - should update items matching predicate", () => {
583583 onRefetch: () => {},
584584 });
585585
586 helpers.arrayUpdateItem(
586 helpers.objArrayUpdate(
587587 queryTest,
588588 ["items"],
589589 (item) => item.id === 2,
......@@ -608,7 +608,7 @@ test("arrayUpdateItem - should update multiple items", () => {
608608 onRefetch: () => {},
609609 });
610610
611 helpers.arrayUpdateItem(
611 helpers.objArrayUpdate(
612612 queryTest,
613613 ["items"],
614614 (item) => item.id > 1,
......@@ -629,7 +629,7 @@ test("arrayUpdateItem - predicate receives index", () => {
629629 onRefetch: () => {},
630630 });
631631
632 helpers.arrayUpdateItem(
632 helpers.objArrayUpdate(
633633 queryTest,
634634 ["items"],
635635 (_item, index) => index === 0,
......@@ -653,7 +653,7 @@ test("arrayInsertIndex - should insert at specific index", () => {
653653 onRefetch: () => {},
654654 });
655655
656 helpers.arrayInsertIndex(
656 helpers.objArrayInsertIndex(
657657 queryTest,
658658 ["items"],
659659 1,
......@@ -680,7 +680,7 @@ test("arrayInsertIndex - should insert at beginning", () => {
680680 onRefetch: () => {},
681681 });
682682
683 helpers.arrayInsertIndex(
683 helpers.objArrayInsertIndex(
684684 queryTest,
685685 ["tags"],
686686 0,
......@@ -699,7 +699,7 @@ test("arrayInsertIndex - should insert at end", () => {
699699 onRefetch: () => {},
700700 });
701701
702 helpers.arrayInsertIndex(
702 helpers.objArrayInsertIndex(
703703 queryTest,
704704 ["tags"],
705705 3,
......@@ -718,7 +718,7 @@ test("arrayInsertIndex - should insert multiple items", () => {
718718 onRefetch: () => {},
719719 });
720720
721 helpers.arrayInsertIndex(
721 helpers.objArrayInsertIndex(
722722 queryTest,
723723 ["tags"],
724724 1,
......@@ -788,9 +788,9 @@ test("integration - multiple operations work together", () => {
788788
789789 // Perform multiple operations
790790 helpers.objIncrement(queryTest, ["count"], 5);
791 helpers.arrayPush(queryTest, ["tags"], "delta");
791 helpers.objArrayPush(queryTest, ["tags"], "delta");
792792 helpers.objToggle(queryTest, ["active"]);
793 helpers.arrayRemoveItem(queryTest, ["items"], (item) => item.id === 2);
793 helpers.objArrayRemove(queryTest, ["items"], (item) => item.id === 2);
794794
795795 const result = client.getQueryData<TestData>(queryTest.queryKey);
796796 assertEquals(result?.count, 15);
tsconfig.json+2-4
......@@ -15,10 +15,8 @@
1515 "noEmit": true,
1616 "allowImportingTsExtensions": true,
1717 "jsx": "react-jsx",
18 "types": ["react"],
19 "paths": {
20 "@clo/react-mutation": ["./src/index.ts"]
21 }
18 "verbatimModuleSyntax": true,
19 "types": ["react"]
2220 },
2321 "include": ["src/**/*", "test/**/*"],
2422 "exclude": ["node_modules"]