From b222f6adf6944284280e7ff42bdb6bb2242bce84 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 27 Jan 2026 18:21:53 -0800 Subject: [PATCH] feat: prepare beta 1 for jsr --- LICENSE | 7 + example/tsconfig.json | 2 +- jsr.json | 22 + package.json | 2 +- readme.md | 58 +- src/batch.ts | 6 +- src/{index.ts => mod.ts} | 10 +- src/object-path.ts | 2 - src/tanstack-query.ts | 850 ++++++++++++++++------------ test/batch.test.ts | 1 - test/tanstack-query-helpers.test.ts | 30 +- tsconfig.json | 6 +- 12 files changed, 593 insertions(+), 403 deletions(-) create mode 100644 LICENSE create mode 100644 jsr.json rename src/{index.ts => mod.ts} (56%) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..f77642ac3445db051fa1915c700cae198a9fa74b --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +ISC License + +Copyright 2026 clover caruso + +Permission 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. + +THE 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. diff --git a/example/tsconfig.json b/example/tsconfig.json index fef96fc192114de0cb2849ce685a6e2be67a02d2..f7a694d2fba53195e12bcfa4b4fea180949cc913 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -17,7 +17,7 @@ "allowImportingTsExtensions": true, "jsx": "react-jsx", "paths": { - "@clo/react-mutation": ["../src/index.ts"] + "@clo/react-mutation": ["../src/mod.ts"] } }, "include": ["src/**/*"], diff --git a/jsr.json b/jsr.json new file mode 100644 index 0000000000000000000000000000000000000000..db194e6070cf6f6c3c6a1c06ae6b816171eb3014 --- /dev/null +++ b/jsr.json @@ -0,0 +1,22 @@ +{ + "name": "@clo/react-mutation", + "version": "1.0.0-beta.1", + "exports": { + ".": "./src/mod.ts", + "./tanstack-query.ts": "./src/tanstack-query.ts", + "./object-path.ts": "./src/object-path.ts" + }, + "imports": { + "@tanstack/react-query": "npm:@tanstack/react-query@^5", + "react": "npm:react@^19" + }, + "publish": { + "include": [ + "LICENSE", + "README.md", + "src/**/*", + "test/**/*" + ] + }, + "license": "ISC" +} diff --git a/package.json b/package.json index 40999d3b82ff2cb8a4d4277ca1ff32a0a809eadd..1b953608c49f3fb704d7da477075e8291a710fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "0.0.0", + "version": "0.1.0", "private": true, "description": "", "license": "ISC", diff --git a/readme.md b/readme.md index 6bf1b07612cb44403298f5b0ccdc2ec9fbfc774f..a2e4a3fd5cf6057c4332cfb493a34c64085bdf30 100644 --- a/readme.md +++ b/readme.md @@ -40,7 +40,8 @@ export const mutations = new MutationClient({ // All properties in `context` are available within mutation functions. context: { client: queryClient, - // Can add easy helpers for your codebase. + // Can add any easy helpers for your codebase. + // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet`) get: (k: QueryKey) => client.getQueryData(k), }, @@ -61,17 +62,11 @@ export const mutations = new MutationClient({ ### Queued Mutations -A queued mutation is defined with `mutations.defineQueued`. These are useful for creating +A queued mutation is defined with `mutations.defineQueued`. ```tsx -const queryCounter = queryOptions({ - queryKey: ["items"], - queryFn: (): Promise => fetch(), -}); -const queryItem = (id: string) => queryOptions({ - queryKey: ["items", id], - queryFn: (): Promise => fetch(), -}); +const queryItemList = queryOptions({ ... }); +const queryItem = (id: string) => queryOptions({ ... }); const mutDeleteItem = mutations.defineQueued({ // `mutate` comes first, is only worried about syncing with the backend. @@ -80,13 +75,13 @@ const mutDeleteItem = mutations.defineQueued({ if (!response.ok) throw new Error(`HTTP ${response.status}`); }, - optimistic({ helpers, args: [amount] }) { - helpers.removeFromArray(queryItemList, ".", (n) => ); - helpers.removeQuery(queryItem) + optimistic({ client, get, helpers, args: [id] }) { + helpers.arrayRemove(queryItemList, (item) => item === id); + helpers.removeQuery(queryItem); }, - describe({ client, args: [id] }) { - const { title } = client.getQueryData(queryItem().queryKey); + describe({ get, args: [id] }) { + const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; return `delete '${title}'`; }, @@ -105,3 +100,36 @@ export function Example({ id }: { id: string }) { ); } ``` + +### Batched Mutations + +A batched mutation is defined with `mutations.defineBatched`. + +```tsx +const mutSetItemName = mutationClient.defineBatched({ + mode: "debounce", + time: 200, + + // start by mutating the optimistic state + optimistic({ helpers }, id: string, name: string) { + helpers.objSet(queryItem(id), ["title"], name); + }, + // a value is snapshot before calling `optimistic` and after the + // timer. if the snapshots differ, the `commit` function is called. + getValue: ({ get }) => get(queryCounter)?.title ?? "", + + // batch the same `id`s together + key: ({ args: [id] }) => id, + + // commit the result to the backend + async commit({ initial, current, args: [id] }) { + const response = await fetch(`/items/${id}`, { + method: "patch", + body: JSON.stringify({ title: current }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }, + + describe: ({ get }) => `rename '${get(queryItem())?.title ?? 'unknown'}'`, +}); +``` diff --git a/src/batch.ts b/src/batch.ts index 486467a888391353af8135f36f6fe10d7b1c2193..5f3feaced24afdde2e8dfd028a6d1af433db807a 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -39,7 +39,7 @@ export interface BatchMutationOptions< * Commit the optimistic state. Throw on failure. */ commit: ( - context: BatchMutatorArgs, Optimistic, Config>, + context: BatchCommitContext, Optimistic, Config>, ) => Promise; /** * Used in error messages and debug tools. @@ -48,7 +48,7 @@ export interface BatchMutationOptions< describe: | string | (( - context: BatchMutatorArgs, Optimistic, Config>, + context: BatchCommitContext, Optimistic, Config>, ) => string); /** * Refetch all of the data this mutation could have affected. @@ -64,7 +64,7 @@ export type BatchOptimisticContext = helpers: Config["optimisticHelpers"]; }; -export type BatchMutatorArgs< +export type BatchCommitContext< Args, Optimistic, Config extends MutationClientConfig, diff --git a/src/index.ts b/src/mod.ts similarity index 56% rename from src/index.ts rename to src/mod.ts index 3fe4d607e1244a7e07004f83de25e6c077918c07..21ac9148d39de84c0e44e813edf290e4f774d9b0 100644 --- a/src/index.ts +++ b/src/mod.ts @@ -1,9 +1,16 @@ -export { type MutationOptions, type OptimisticContext } from "./queued.ts"; +export type { MutationOptions, OptimisticContext } from "./queued.ts"; +export type { + BatchCommitContext, + BatchMutationOptions, + BatchOptimisticContext, +} from "./batch.ts"; export { MutationClient, + type MutationClientConfig, type MutationClientFromConfig, type MutationClientOptions, } from "./client.ts"; +export type { Mutation, MutationEvent } from "./types.ts"; export { createMutationButton, type MutationButtonProps, @@ -14,4 +21,3 @@ export { type UseMutationResultBase, type UseMutationSuccess, } from "./react.tsx"; -export { queryClientOptimisticHelpers } from "./tanstack-query.ts"; diff --git a/src/object-path.ts b/src/object-path.ts index b01ee228f17318037596c9ff81dfeb3fa61f9dff..436308f37559394d375551d394ca0d79ef3ccd9d 100644 --- a/src/object-path.ts +++ b/src/object-path.ts @@ -1,5 +1,3 @@ -import { assert } from "@std/assert"; - export type AllObjectPaths = T extends ReadonlyArray ? [] | [number, ...AllObjectPaths] : T extends object ? diff --git a/src/tanstack-query.ts b/src/tanstack-query.ts index 96d9baeeb48b8f7de7cf387e9fbc71780235549e..d4c3775295687864978a825a017b7ba2d1c6fa20 100644 --- a/src/tanstack-query.ts +++ b/src/tanstack-query.ts @@ -1,403 +1,535 @@ -import { +import type { QueryClient, QueryFunction, - QueryFunctionContext, QueryKey, - QueryOptions, - SkipToken, Updater, - UseQueryOptions, } from "@tanstack/react-query"; import { type AllObjectPaths, type GetObjectPath, - getPath as getPath, - setPath as setPath, + getPath, + setPath, } from "./object-path.ts"; -import { OptimisticEvents } from "./client.ts"; +import type { OptimisticEvents } from "./client.ts"; -export type QueryKeyAndFn = { - queryKey: QueryKey; +export type QueryKeyAndFn = { + queryKey: Key; queryFn?: QueryFunction | undefined; }; -export function queryClientOptimisticHelpers(client: QueryClient) { - return ({ onRefetch, onRestore }: OptimisticEvents) => { - const refetchHashes: string[] = []; +export function boundQueryClientGet( + queryClient: QueryClient, +): ({ queryKey }: QueryKeyAndFn) => T | undefined { + return function get({ queryKey }: QueryKeyAndFn) { + return queryClient.getQueryData(queryKey); + }; +} - function get({ queryKey }: QueryKeyAndFn) { - return client.getQueryData(queryKey); +class TanstackQueryOptimisticHelpers { + #client: QueryClient; + #onRefetch: OptimisticEvents["onRefetch"]; + #onRestore: OptimisticEvents["onRestore"]; + #refetchHashes: string[] = []; + + constructor(client: QueryClient, { onRefetch, onRestore }: OptimisticEvents) { + this.#client = client; + this.#onRefetch = onRefetch; + this.#onRestore = onRestore; + } + + #get({ queryKey }: QueryKeyAndFn) { + return this.#client.getQueryData(queryKey); + } + + #set( + query: QueryKeyAndFn, + updater: Updater | undefined, NoInfer | undefined>, + ) { + const result = this.#client.setQueryData(query.queryKey, updater); + if (result) { + this.#client.cancelQueries({ queryKey: query.queryKey, exact: true }) + .catch(() => {}); + const state = this.#client.getQueryCache().find({ + queryKey: query.queryKey, + exact: true, + }); + if (!state || this.#refetchHashes.includes(state.queryHash)) return; + this.#refetchHashes.push(state.queryHash); + try { + this.#onRefetch(async () => { + await this.#client.refetchQueries(query); + }); + } catch { /* Ignore expiry */ } } - function set( - query: QueryKeyAndFn, - updater: Updater | undefined, NoInfer | undefined>, - ) { - const result = client.setQueryData(query.queryKey, updater); - if (result) { - client.cancelQueries({ queryKey: query.queryKey, exact: true }) - .catch(() => {}); - const state = client.getQueryCache().find({ - queryKey: query.queryKey, + } + + /** + * Set the entire query data. + * If the query doesn't exist, the new query is created. + */ + set( + queryKey: QueryKeyAndFn, + value: Data | ((prev: Data | undefined) => Data | undefined), + ) { + const prev = this.#get(queryKey); + + const newValue = typeof value === "function" + ? (value as (prev: Data | undefined) => Data | undefined)(prev) + : value; + + this.#set(queryKey, newValue); + this.#onRestore(() => { + if (prev === undefined) { + this.#client.removeQueries({ + queryKey: queryKey.queryKey, exact: true, }); - if (!state || refetchHashes.includes(state.queryHash)) return; - refetchHashes.push(state.queryHash); - onRefetch(async () => { - await client.refetchQueries(query); - }); + } else { + this.#set(queryKey, prev); } + }); + } + + /** + * Update the entire query data. + * If the query doesn't exist, it cancels + */ + updateExisting( + queryKey: QueryKeyAndFn, + value: Data | ((prev: Data) => Data), + ) { + const prev = this.#get(queryKey); + if (!prev) return; + + const newValue = typeof value === "function" + ? (value as (prev: Data | undefined) => Data | undefined)(prev) + : value; + + this.#set(queryKey, newValue); + this.#onRestore(() => { + this.#set(queryKey, prev); + }); + } + + /** + * Set a property at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objSet>( + queryKey: QueryKeyAndFn, + path: Path, + value: + | Exclude, Function> + | ((prev: GetObjectPath) => GetObjectPath), + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists) return; + + this.#set( + queryKey, + (obj) => + obj + ? setPath( + obj, + path, + typeof value === "function" + ? (value as (( + prev: GetObjectPath, + ) => GetObjectPath))(original) + : value, + ) + : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } + + /** + * Increment a numeric property at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objIncrement< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + amount: number = 1, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || typeof original !== "number") return; + + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, (original + amount) as any) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } + + /** + * Decrement a numeric property at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objDecrement< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + amount: number = 1, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || typeof original !== "number") return; + + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, (original - amount) as any) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } + + /** + * Toggle a boolean property at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objToggle>( + queryKey: QueryKeyAndFn, + path: Path, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || typeof original !== "boolean") return; + + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, (!original) as any) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } + + /** + * Shallow merge multiple properties at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objSetMany>( + queryKey: QueryKeyAndFn, + path: Path, + updates: Partial>, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || typeof original !== "object" || original === null) { + return; } - return ({ - /** - * Set the entire query data. - * If the query doesn't exist, the new query is created. - */ - set( - queryKey: QueryKeyAndFn, - value: Data | ((prev: Data | undefined) => Data | undefined), - ) { - const prev = get(queryKey); - - const newValue = typeof value === "function" - ? (value as (prev: Data | undefined) => Data | undefined)(prev) - : value; - - set(queryKey, newValue); - onRestore(() => { - if (prev === undefined) { - client.removeQueries({ queryKey: queryKey.queryKey, exact: true }); - } else { - set(queryKey, prev); - } - }); - }, - - /** - * Update the entire query data. - * If the query doesn't exist, it cancels - */ - updateExisting( - queryKey: QueryKeyAndFn, - value: Data | ((prev: Data) => Data), - ) { - const prev = get(queryKey); - if (!prev) return false; - - const newValue = typeof value === "function" - ? (value as (prev: Data | undefined) => Data | undefined)(prev) - : value; - - set(queryKey, newValue); - onRestore(() => { - set(queryKey, prev); - }); - }, + const merged = { ...original as object, ...updates } as GetObjectPath< + Data, + Path + >; + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, merged) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Set a property at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - objSet>( - queryKey: QueryKeyAndFn, - path: Path, - value: - | Exclude, Function> - | ((prev: GetObjectPath) => GetObjectPath), - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists) return; + /** + * Push item(s) to the end of an array at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayPush< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + ...items: GetObjectPath extends Array ? T[] + : never + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || !Array.isArray(original)) return; - set( - queryKey, - (obj) => - obj - ? setPath( - obj, - path, - typeof value === "function" - ? (value as (( - prev: GetObjectPath, - ) => GetObjectPath))(original) - : value, - ) - : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [...original, ...items]; + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, newArray as any) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Increment a numeric property at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - objIncrement< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - amount: number = 1, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || typeof original !== "number") return; + /** + * Add item(s) to the beginning of an array at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayUnshift< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + ...items: GetObjectPath extends Array ? T[] + : never + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || !Array.isArray(original)) return; - set( - queryKey, - (obj) => obj ? setPath(obj, path, (original + amount) as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [...items, ...original]; + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, newArray as any) : obj, + ); + this.#onRestore(() => { + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Decrement a numeric property at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - objDecrement< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - amount: number = 1, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || typeof original !== "number") return; + /** + * Remove items from an array that match a predicate. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayRemove< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + predicate: ( + item: GetObjectPath extends Array ? T : never, + index: number, + ) => boolean, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || !Array.isArray(original)) return; - set( - queryKey, - (obj) => obj ? setPath(obj, path, (original - amount) as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = original.filter((item, index) => !predicate(item, index)); + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, newArray as any) : obj, + ); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Toggle a boolean property at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - objToggle>( - queryKey: QueryKeyAndFn, - path: Path, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || typeof original !== "boolean") return; + /** + * Update items in an array that match a predicate. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayUpdate< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + predicate: ( + item: GetObjectPath extends Array ? T : never, + index: number, + ) => boolean, + updater: ( + item: GetObjectPath extends Array ? T : never, + ) => GetObjectPath extends Array ? T : never, + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || !Array.isArray(original)) return; - set( - queryKey, - (obj) => obj ? setPath(obj, path, (!original) as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = original.map((item, index) => + predicate(item, index) ? updater(item) : item + ); + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, newArray as any) : obj, + ); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Shallow merge multiple properties at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - objSetMany>( - queryKey: QueryKeyAndFn, - path: Path, - updates: Partial>, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || typeof original !== "object" || original === null) { - return; - } + /** + * Insert item(s) at a specific index in an array. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayInsertIndex< + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + index: number, + ...items: GetObjectPath extends Array ? T[] + : never + ) { + const prev = this.#get(queryKey); + if (!prev) return; + const { value: original, exists } = getPath(prev, path); + if (!exists || !Array.isArray(original)) return; - const merged = { ...original as object, ...updates } as GetObjectPath< - Data, - Path - >; - set( - queryKey, - (obj) => obj ? setPath(obj, path, merged) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [ + ...original.slice(0, index), + ...items, + ...original.slice(index), + ]; + this.#set( + queryKey, + (obj) => obj ? setPath(obj, path, newArray as any) : obj, + ); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); + }); + } - /** - * Push item(s) to the end of an array at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - arrayPush>( - queryKey: QueryKeyAndFn, - path: Path, - ...items: GetObjectPath extends Array ? T[] - : never - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + /** + * Push item(s) to the end of an array at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + arrayPush(queryKey: QueryKeyAndFn, ...items: Data[]) { + const prev = this.#get(queryKey); + if (!prev || !Array.isArray(prev)) return; - const newArray = [...original, ...items]; - set( - queryKey, - (obj) => obj ? setPath(obj, path, newArray as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [...prev, ...items]; + this.#set(queryKey, newArray); + this.#onRestore(() => { + this.#set( + queryKey, + (old) => old ? old.filter((x) => !items.includes(x)) : old, + ); + }); + } - /** - * Add item(s) to the beginning of an array at an object path. - * If the query or path doesn't exist, the updater is skipped. - */ - arrayUnshift< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - ...items: GetObjectPath extends Array ? T[] - : never - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + /** + * Add item(s) to the beginning of an array at an object path. + * If the query or path doesn't exist, the updater is skipped. + */ + arrayUnshift(queryKey: QueryKeyAndFn, ...items: Data[]) { + const prev = this.#get(queryKey); + if (!prev || !Array.isArray(prev)) return; - const newArray = [...items, ...original]; - set( - queryKey, - (obj) => obj ? setPath(obj, path, newArray as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [...items, ...prev]; + this.#set(queryKey, newArray); + this.#onRestore(() => { + this.#set( + queryKey, + (old) => old ? old.filter((x) => !items.includes(x)) : old, + ); + }); + } - /** - * Remove items from an array that match a predicate. - * If the query or path doesn't exist, the updater is skipped. - */ - arrayRemoveItem< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - predicate: ( - item: GetObjectPath extends Array ? T : never, - index: number, - ) => boolean, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + /** + * Remove items from an array that match a predicate. + * If the query or path doesn't exist, the updater is skipped. + */ + arrayRemove( + queryKey: QueryKeyAndFn, + predicate: ( + item: Data, + index: number, + ) => boolean, + ) { + const prev = this.#get(queryKey); + if (!prev || !Array.isArray(prev)) return; - const newArray = original.filter((item, index) => - !predicate(item, index) - ); - set( - queryKey, - (obj) => obj ? setPath(obj, path, newArray as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = prev.filter((item, index) => !predicate(item, index)); + this.#set(queryKey, newArray); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, prev); + }); + } - /** - * Update items in an array that match a predicate. - * If the query or path doesn't exist, the updater is skipped. - */ - arrayUpdateItem< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - predicate: ( - item: GetObjectPath extends Array ? T : never, - index: number, - ) => boolean, - updater: ( - item: GetObjectPath extends Array ? T : never, - ) => GetObjectPath extends Array ? T : never, - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + /** + * Update items in an array that match a predicate. + * If the query or path doesn't exist, the updater is skipped. + */ + arrayUpdate( + queryKey: QueryKeyAndFn, + predicate: ( + item: Data, + index: number, + ) => boolean, + updater: (item: Data) => Data, + ) { + const prev = this.#get(queryKey); + if (!prev || !Array.isArray(prev)) return; - const newArray = original.map((item, index) => - predicate(item, index) ? updater(item) : item - ); - set( - queryKey, - (obj) => obj ? setPath(obj, path, newArray as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = prev.map((item, index) => + predicate(item, index) ? updater(item) : item + ); + this.#set(queryKey, newArray); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, prev); + }); + } - /** - * Insert item(s) at a specific index in an array. - * If the query or path doesn't exist, the updater is skipped. - */ - arrayInsertIndex< - Data extends object, - const Path extends AllObjectPaths, - >( - queryKey: QueryKeyAndFn, - path: Path, - index: number, - ...items: GetObjectPath extends Array ? T[] - : never - ) { - const prev = get(queryKey); - if (!prev) return; - const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + /** + * Insert item(s) at a specific index in an array. + * If the query or path doesn't exist, the updater is skipped. + */ + arrayInsertIndex( + queryKey: QueryKeyAndFn, + index: number, + ...items: Data[] + ) { + const prev = this.#get(queryKey); + if (!prev || !Array.isArray(prev)) return; - const newArray = [ - ...original.slice(0, index), - ...items, - ...original.slice(index), - ]; - set( - queryKey, - (obj) => obj ? setPath(obj, path, newArray as any) : obj, - ); - onRestore(() => { - set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); - }); - }, + const newArray = [ + ...prev.slice(0, index), + ...items, + ...prev.slice(index), + ]; + this.#set(queryKey, newArray); + this.#onRestore(() => { + // TODO: splice items back in case original changed + this.#set(queryKey, prev); + }); + } - /** - * Remove a query from the cache entirely. - */ - removeQuery(queryKey: QueryKeyAndFn) { - const prev = get(queryKey); - if (!prev) return; + /** + * Remove a query from the cache entirely. + */ + removeQuery(queryKey: QueryKeyAndFn) { + const prev = this.#get(queryKey); + if (!prev) return; - client.removeQueries({ queryKey: queryKey.queryKey, exact: true }); - onRestore(() => { - set(queryKey, prev); - }); - }, + this.#client.removeQueries({ queryKey: queryKey.queryKey, exact: true }); + this.#onRestore(() => { + this.#set(queryKey, prev); }); - }; + } +} + +export function queryClientOptimisticHelpers( + client: QueryClient, +): (e: OptimisticEvents) => TanstackQueryOptimisticHelpers { + return (events) => new TanstackQueryOptimisticHelpers(client, events); } diff --git a/test/batch.test.ts b/test/batch.test.ts index 931e1893714a189393805a2b99c49bbc2ff593ee..31bfd3543a501cede9357717d99442e2e72a8db2 100644 --- a/test/batch.test.ts +++ b/test/batch.test.ts @@ -2,7 +2,6 @@ import { assertEquals, assertRejects } from "@std/assert"; import { MutationClient } from "../src/client.ts"; import type { MutationEvent } from "../src/types.ts"; import { test } from "vitest"; -import { UIEventHandler } from "react"; // Shared test store for optimistic updates const testStore = new Map(); diff --git a/test/tanstack-query-helpers.test.ts b/test/tanstack-query-helpers.test.ts index cb39c31a0c4e444cd2e1dc0f88418711fe7b4fce..a62cb28b301bbc8b9a1a6f1c25d72579975602e3 100644 --- a/test/tanstack-query-helpers.test.ts +++ b/test/tanstack-query-helpers.test.ts @@ -440,7 +440,7 @@ test("arrayPush - should add items to end of array", () => { onRefetch: () => {}, }); - helpers.arrayPush( + helpers.objArrayPush( queryTest, ["items"], { id: 4, label: "fourth" }, @@ -466,7 +466,7 @@ test("arrayPush - should work with simple arrays", () => { onRefetch: () => {}, }); - helpers.arrayPush(queryTest, ["tags"], "delta", "epsilon"); + helpers.objArrayPush(queryTest, ["tags"], "delta", "epsilon"); const result = client.getQueryData(queryTest.queryKey); assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta", "epsilon"]); @@ -485,7 +485,7 @@ test("arrayUnshift - should add items to beginning of array", () => { onRefetch: () => {}, }); - helpers.arrayUnshift( + helpers.objArrayUnshift( queryTest, ["items"], { id: 0, label: "zeroth" }, @@ -516,7 +516,7 @@ test("arrayRemoveItem - should remove items matching predicate", () => { onRefetch: () => {}, }); - helpers.arrayRemoveItem( + helpers.objArrayRemove( queryTest, ["items"], (item) => item.id === 2, @@ -541,7 +541,7 @@ test("arrayRemoveItem - should remove multiple items", () => { onRefetch: () => {}, }); - helpers.arrayRemoveItem( + helpers.objArrayRemove( queryTest, ["items"], (item) => item.id > 1, @@ -560,7 +560,7 @@ test("arrayRemoveItem - should work with simple arrays", () => { onRefetch: () => {}, }); - helpers.arrayRemoveItem( + helpers.objArrayRemove( queryTest, ["tags"], (tag) => tag === "beta", @@ -583,7 +583,7 @@ test("arrayUpdateItem - should update items matching predicate", () => { onRefetch: () => {}, }); - helpers.arrayUpdateItem( + helpers.objArrayUpdate( queryTest, ["items"], (item) => item.id === 2, @@ -608,7 +608,7 @@ test("arrayUpdateItem - should update multiple items", () => { onRefetch: () => {}, }); - helpers.arrayUpdateItem( + helpers.objArrayUpdate( queryTest, ["items"], (item) => item.id > 1, @@ -629,7 +629,7 @@ test("arrayUpdateItem - predicate receives index", () => { onRefetch: () => {}, }); - helpers.arrayUpdateItem( + helpers.objArrayUpdate( queryTest, ["items"], (_item, index) => index === 0, @@ -653,7 +653,7 @@ test("arrayInsertIndex - should insert at specific index", () => { onRefetch: () => {}, }); - helpers.arrayInsertIndex( + helpers.objArrayInsertIndex( queryTest, ["items"], 1, @@ -680,7 +680,7 @@ test("arrayInsertIndex - should insert at beginning", () => { onRefetch: () => {}, }); - helpers.arrayInsertIndex( + helpers.objArrayInsertIndex( queryTest, ["tags"], 0, @@ -699,7 +699,7 @@ test("arrayInsertIndex - should insert at end", () => { onRefetch: () => {}, }); - helpers.arrayInsertIndex( + helpers.objArrayInsertIndex( queryTest, ["tags"], 3, @@ -718,7 +718,7 @@ test("arrayInsertIndex - should insert multiple items", () => { onRefetch: () => {}, }); - helpers.arrayInsertIndex( + helpers.objArrayInsertIndex( queryTest, ["tags"], 1, @@ -788,9 +788,9 @@ test("integration - multiple operations work together", () => { // Perform multiple operations helpers.objIncrement(queryTest, ["count"], 5); - helpers.arrayPush(queryTest, ["tags"], "delta"); + helpers.objArrayPush(queryTest, ["tags"], "delta"); helpers.objToggle(queryTest, ["active"]); - helpers.arrayRemoveItem(queryTest, ["items"], (item) => item.id === 2); + helpers.objArrayRemove(queryTest, ["items"], (item) => item.id === 2); const result = client.getQueryData(queryTest.queryKey); assertEquals(result?.count, 15); diff --git a/tsconfig.json b/tsconfig.json index b380597b73b941137df204d914762779c75cb83a..74acd2e711fc0a9ec870a4c9676ec435b461993e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,10 +15,8 @@ "noEmit": true, "allowImportingTsExtensions": true, "jsx": "react-jsx", - "types": ["react"], - "paths": { - "@clo/react-mutation": ["./src/index.ts"] - } + "verbatimModuleSyntax": true, + "types": ["react"] }, "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules"] -- 2.54.0