From 412b10d5ee37350264ed510a0b0969b19f01fa83 Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Wed, 3 Sep 2025 22:01:53 +0200 Subject: [PATCH 1/8] feat: typesafe setQueryData on OpenapiQueryClient --- packages/openapi-react-query/src/index.ts | 36 ++++- .../openapi-react-query/test/index.test.tsx | 128 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index af4eec6c9..3ce88ca4b 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -164,12 +164,27 @@ export type UseMutationMethod UseMutationResult; +export type SetQueryDataMethod>, Media extends MediaType> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, +>( + method: Method, + path: Path, + updater: ( + oldData: Required>["data"] | undefined, + ) => Required>["data"], + queryClient: QueryClient, + init?: Init, +) => void; + export interface OpenapiQueryClient { queryOptions: QueryOptionsFunction; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; useInfiniteQuery: UseInfiniteQueryMethod; useMutation: UseMutationMethod; + setQueryData: SetQueryDataMethod; } export type MethodResponse< @@ -193,7 +208,10 @@ export default function createClient>) => { const mth = method.toUpperCase() as Uppercase; const fn = client[mth] as ClientMethod; - const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any + const { data, error, response } = await fn(path, { + signal, + ...(init as any), + }); // TODO: find a way to avoid as any if (error) { throw error; } @@ -270,5 +288,21 @@ export default function createClient( + method: Method, + path: Path, + updater: (oldData: any) => any, + queryClient: QueryClient, + init?: Init, + ) { + const queryKey = (init === undefined ? [method, path] : [method, path, init]); + queryClient.setQueryData(queryKey, updater as any); + }, }; } diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 4e2801a15..627fcf262 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -75,6 +75,7 @@ describe("client", () => { expect(client).toHaveProperty("useQuery"); expect(client).toHaveProperty("useSuspenseQuery"); expect(client).toHaveProperty("useMutation"); + expect(client).toHaveProperty("setQueryData"); }); describe("queryOptions", () => { @@ -1202,4 +1203,131 @@ describe("client", () => { expect(result.current.data).toEqual([1, 2, 3, 4, 5, 6]); }); }); + + describe("setQueryData", () => { + it("should set query data with type safety", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = { title: "Initial Title", body: "Initial Body" }; + const updatedData = { title: "Updated Title", body: "Updated Body" }; + + // Set initial data + queryClient.setQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + initialData, + ); + + // Update data using setQueryData + client.setQueryData( + "get", + "/blogposts/{post_id}", + (oldData) => { + expectTypeOf(oldData).toEqualTypeOf< + | { + title: string; + body: string; + publish_date?: number; + } + | undefined + >(); + return updatedData; + }, + queryClient, + { + params: { path: { post_id: "1" } }, + }, + ); + + // Verify data was updated + const cachedData = queryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(cachedData).toEqual(updatedData); + }); + + it("should work with queries without init params", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = ["item1", "item2"]; + const updatedData = ["updated1", "updated2"]; + + // Set initial data + queryClient.setQueryData(client.queryOptions("get", "/string-array").queryKey, initialData); + + // Update data using setQueryData + client.setQueryData( + "get", + "/string-array", + (oldData) => { + expectTypeOf(oldData).toEqualTypeOf(); + return updatedData; + }, + queryClient, + ); + + // Verify data was updated + const cachedData = queryClient.getQueryData(client.queryOptions("get", "/string-array").queryKey); + expect(cachedData).toEqual(updatedData); + }); + + it("should use provided custom queryClient", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + const customQueryClient = new QueryClient({}); + + const initialData = { title: "Initial", body: "Body" }; + const updatedData = { title: "Updated", body: "Body" }; + + // Set initial data in custom client + customQueryClient.setQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + initialData, + ); + + // Update data using setQueryData with custom client + client.setQueryData("get", "/blogposts/{post_id}", (oldData) => updatedData, customQueryClient, { + params: { path: { post_id: "1" } }, + }); + + // Verify data was updated in custom client + const cachedData = customQueryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(cachedData).toEqual(updatedData); + + // Verify main client was not affected + const mainCachedData = queryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(mainCachedData).toBeUndefined(); + }); + + it("should enforce type safety on updater function return type", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + // This should cause a TypeScript error because the return type is not the same as the expected type + // @ts-expect-error + client.setQueryData("get", "/blogposts/{post_id}", () => { + return { invalidField: "invalid" }; // This should error + }, queryClient, { + params: { path: { post_id: "1" } } + }); + }); + }); }); From 58bf9d5c78c79d2094b091d802304b9b4df3c4e5 Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Wed, 3 Sep 2025 22:03:23 +0200 Subject: [PATCH 2/8] feat: openapi-react-query example project with typesafe setQueryData --- .../examples/setquerydata-example/README.md | 92 ++++++++++++ .../examples/setquerydata-example/api.d.ts | 141 ++++++++++++++++++ .../examples/setquerydata-example/demo.ts | 69 +++++++++ .../setquerydata-example/openapi.yaml | 92 ++++++++++++ .../setquerydata-example/package.json | 20 +++ .../setquerydata-example/tsconfig.json | 16 ++ pnpm-lock.yaml | 22 +++ 7 files changed, 452 insertions(+) create mode 100644 packages/openapi-react-query/examples/setquerydata-example/README.md create mode 100644 packages/openapi-react-query/examples/setquerydata-example/api.d.ts create mode 100644 packages/openapi-react-query/examples/setquerydata-example/demo.ts create mode 100644 packages/openapi-react-query/examples/setquerydata-example/openapi.yaml create mode 100644 packages/openapi-react-query/examples/setquerydata-example/package.json create mode 100644 packages/openapi-react-query/examples/setquerydata-example/tsconfig.json diff --git a/packages/openapi-react-query/examples/setquerydata-example/README.md b/packages/openapi-react-query/examples/setquerydata-example/README.md new file mode 100644 index 000000000..ef77276e2 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/README.md @@ -0,0 +1,92 @@ +# setQueryData Example + +This example demonstrates the type-safe `setQueryData` functionality in `openapi-react-query`. + +## Overview + +The `setQueryData` method allows you to update cached query data with full TypeScript type safety. The updater function receives the current cached data and must return data of the same type, ensuring type consistency. + +## Setup + +1. Generate TypeScript types from the OpenAPI spec: + +```bash +npm run generate-types +``` + +2. Run TypeScript check to see type safety in action: + +```bash +npm run type-check +``` + +## Examples + +### 1. Update posts list after creating a new post + +```typescript +// After creating a post via POST /posts, update the GET /posts cache +$api.setQueryData( + "get", + "/posts", + (oldPosts) => { + // TypeScript ensures oldPosts is Post[] | undefined + // and we must return Post[] + const newPost = { id: "123", title: "New Post", content: "Content" }; + return oldPosts ? [...oldPosts, newPost] : [newPost]; + }, + queryClient, +); +``` + +### 2. Update a single post after editing + +```typescript +// After updating a post, update the specific post cache +$api.setQueryData( + "get", + "/posts/{id}", + (oldPost) => { + // TypeScript ensures oldPost is Post | undefined + // and we must return Post + if (!oldPost) return oldPost; + return { ...oldPost, title: "Updated Title" }; + }, + queryClient, + { params: { path: { id: "123" } } }, +); +``` + +### 3. Clear cache + +```typescript +// Clear the posts cache +$api.setQueryData( + "get", + "/posts", + () => [], // Must return Post[] + queryClient, +); +``` + +## Type Safety + +The `setQueryData` method enforces type safety: + +- ✅ **Valid**: Returning the correct type +- ❌ **Invalid**: Returning a different type (TypeScript error) + +See `demo-invalid.ts` for examples of invalid usage that will cause TypeScript errors. + +## Running the Example + +```bash +# Generate types +npm run generate-types + +# Check types (should pass) +npm run type-check + +# Check invalid examples (will show type errors) +npx tsc --noEmit demo-invalid.ts +``` diff --git a/packages/openapi-react-query/examples/setquerydata-example/api.d.ts b/packages/openapi-react-query/examples/setquerydata-example/api.d.ts new file mode 100644 index 000000000..d4481482f --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/api.d.ts @@ -0,0 +1,141 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/posts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all posts */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"][]; + }; + }; + }; + }; + put?: never; + /** Create a new post */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PostInput"]; + }; + }; + responses: { + /** @description Post created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/posts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single post */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + /** @description Post not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Post: { + id: string; + title: string; + content: string; + /** Format: date-time */ + createdAt?: string; + }; + PostInput: { + title: string; + content: string; + }; + Error: { + code: number; + message: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/openapi-react-query/examples/setquerydata-example/demo.ts b/packages/openapi-react-query/examples/setquerydata-example/demo.ts new file mode 100644 index 000000000..66dfe55ea --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/demo.ts @@ -0,0 +1,69 @@ +// Example demonstrating setQueryData with type safety +import createFetchClient from "openapi-fetch"; +import createClient from "../../src"; +// NOTE: If running locally, ensure you import from the local source, not the package name, to avoid module resolution errors. +import { QueryClient } from "@tanstack/react-query"; +import type { paths, components } from "./api"; + +type Post = components["schemas"]["Post"]; + +// Create clients +const fetchClient = createFetchClient({ + baseUrl: "https://api.example.com", +}); +const $api = createClient(fetchClient); +const queryClient = new QueryClient(); + +// Example 1: Update posts list after creating a new post +async function createPostAndUpdateCache() { + // Simulate creating a new post + const newPost: Post = { + id: "123", + title: "New Post", + content: "Post content", + createdAt: new Date().toISOString(), + }; + + // Update the posts list cache with the new post + $api.setQueryData( + "get", + "/posts", + (oldPosts) => { + return oldPosts ? [...oldPosts, newPost] : [newPost]; + }, + queryClient, + {}, + ); + + return newPost; +} + +// Example 2: Update a single post after editing +async function updatePostAndUpdateCache(postId: string, updates: { title?: string; content?: string }) { + // Update the specific post cache + $api.setQueryData( + "get", + "/posts/{id}", + (oldPost) => { + // TypeScript ensures oldPost is Post | undefined + // and we must return Post + if (!oldPost) throw new Error("No post in cache"); + return { ...oldPost, ...updates }; + }, + queryClient, + { params: { path: { id: postId } } }, + ); +} + +// Example 3: Clear posts cache +function clearPostsCache() { + $api.setQueryData( + "get", + "/posts", + () => [], // Return empty array + queryClient, + {}, + ); +} + +export { createPostAndUpdateCache, updatePostAndUpdateCache, clearPostsCache }; diff --git a/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml b/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml new file mode 100644 index 000000000..6415cdc87 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.0 +info: + title: Blog API + version: 1.0.0 +paths: + /posts: + get: + summary: Get all posts + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + post: + summary: Create a new post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostInput" + responses: + "201": + description: Post created + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + /posts/{id}: + get: + summary: Get a single post + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + description: Post not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Post: + type: object + required: + - id + - title + - content + properties: + id: + type: string + title: + type: string + content: + type: string + createdAt: + type: string + format: date-time + PostInput: + type: object + required: + - title + - content + properties: + title: + type: string + content: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: number + message: + type: string diff --git a/packages/openapi-react-query/examples/setquerydata-example/package.json b/packages/openapi-react-query/examples/setquerydata-example/package.json new file mode 100644 index 000000000..a707527a2 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/package.json @@ -0,0 +1,20 @@ +{ + "name": "openapi-react-query-setquerydata-example", + "version": "1.0.0", + "description": "Example demonstrating setQueryData functionality with type safety", + "scripts": { + "generate-types": "openapi-typescript ./openapi.yaml -o ./api.d.ts", + "type-check": "tsc --noEmit", + "build": "tsc" + }, + "dependencies": { + "@tanstack/react-query": "^5.84.2", + "openapi-fetch": "workspace:^", + "openapi-react-query": "workspace:^" + }, + "devDependencies": { + "@types/node": "^22.17.1", + "openapi-typescript": "workspace:^", + "typescript": "^5.9.2" + } +} diff --git a/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json b/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json new file mode 100644 index 000000000..0ab34cf27 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "skipLibCheck": true, + "sourceRoot": ".", + "jsx": "react-jsx", + "target": "ES2022", + "types": ["vitest/globals"] + }, + "include": ["src", "test", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8a37f712..d04c662ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,28 @@ importers: specifier: ^6.0.0 version: 6.0.0(react@18.3.1) + packages/openapi-react-query/examples/setquerydata-example: + dependencies: + '@tanstack/react-query': + specifier: ^5.84.2 + version: 5.84.2(react@18.3.1) + openapi-fetch: + specifier: workspace:^ + version: link:../../../openapi-fetch + openapi-react-query: + specifier: workspace:^ + version: link:../.. + devDependencies: + '@types/node': + specifier: ^22.17.1 + version: 22.17.1 + openapi-typescript: + specifier: workspace:^ + version: link:../../../openapi-typescript + typescript: + specifier: ^5.9.2 + version: 5.9.2 + packages/openapi-typescript: dependencies: '@redocly/openapi-core': From b12ca8fb8fe00556a3f674eac818ff9a9976f27f Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Wed, 3 Sep 2025 22:20:31 +0200 Subject: [PATCH 3/8] feat: add docs page for set-query-data --- docs/.vitepress/en.ts | 1 + docs/openapi-react-query/set-query-data.md | 98 ++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 docs/openapi-react-query/set-query-data.md diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index b70514df7..3028755b5 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: "useSuspenseQuery", link: "/use-suspense-query" }, { text: "useInfiniteQuery", link: "/use-infinite-query" }, { text: "queryOptions", link: "/query-options" }, + { text: "setQueryData", link: "/set-query-data" }, ], }, { diff --git a/docs/openapi-react-query/set-query-data.md b/docs/openapi-react-query/set-query-data.md new file mode 100644 index 000000000..ca025a122 --- /dev/null +++ b/docs/openapi-react-query/set-query-data.md @@ -0,0 +1,98 @@ +--- +title: setQueryData +--- + +# {{ $frontmatter.title }} + +The `setQueryData` method lets you directly update the cached data for a specific query (by method, path, and params) in a type-safe way, just like [QueryClient.setQueryData](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata). + +- The updater function is fully type-safe and infers the correct data type from your OpenAPI schema. +- No manual type annotations are needed for the updater argument. +- Works with any query created by this client. + +::: tip +You can find more information about `setQueryData` on the [@tanstack/react-query documentation](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata). +::: + +## Example + +::: code-group + +```tsx [src/app.tsx] +import { $api } from './api' + +export const App = () => { + // Update the cached list of posts. + const handleAddPost = (newPost) => { + $api.setQueryData( + 'get', + '/posts', + (oldPosts = []) => [...oldPosts, newPost], + queryClient + ) + } + + // Update a single post by id. + const handleEditPost = (updatedPost) => { + $api.setQueryData( + 'get', + '/posts/{post_id}', + (oldPost) => ({ ...oldPost, ...updatedPost }), + queryClient, + { params: { path: { post_id: updatedPost.id } } } + ) + } + + return null +} +``` + +```ts [src/api.ts] +import createFetchClient from 'openapi-fetch' +import createClient from 'openapi-react-query' +import type { paths } from './my-openapi-3-schema' // generated by openapi-typescript + +const fetchClient = createFetchClient({ + baseUrl: 'https://myapi.dev/v1/', +}) +export const $api = createClient(fetchClient) +``` + +::: + +## Type Safety Example + +If you try to update the cache with the wrong type, TypeScript will show an error: + +```ts [❌ TypeScript Error Example] +$api.setQueryData( + 'get', + '/posts', + // ❌ This updater returns a string, but the cached data should be an array of posts. + (oldPosts) => 'not an array', // TypeScript Error: Type 'string' is not assignable to type 'Post[]'. + queryClient +) +``` + +## Api + +```tsx +$api.setQueryData(method, path, updater, queryClient, options?); +``` + +**Arguments** + +- `method` **(required)** + - The HTTP method for the query (e.g. "get"). +- `path` **(required)** + - The path for the query (e.g. "/posts/{post_id}"). +- `updater` **(required)** + - A function that receives the current cached data and returns the new data. The argument is fully type-inferred from your OpenAPI schema. +- `queryClient` **(required)** + - The [QueryClient](https://tanstack.com/query/latest/docs/framework/react/reference/QueryClient) instance to update. +- `options` + - The fetch options (e.g. params) for the query. Only required if your endpoint requires parameters. + +## TypeScript Inference + +The `updater` function argument is fully type-inferred from your OpenAPI schema, so you get autocompletion and type safety for the cached data. No manual type annotations are needed. From bd6f3d59236a8a9a219191fe12a69ab4da93656f Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Wed, 3 Sep 2025 22:39:33 +0200 Subject: [PATCH 4/8] chore: add changeset for setQueryData minor bump --- .changeset/spicy-monkeys-battle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-monkeys-battle.md diff --git a/.changeset/spicy-monkeys-battle.md b/.changeset/spicy-monkeys-battle.md new file mode 100644 index 000000000..33f182d44 --- /dev/null +++ b/.changeset/spicy-monkeys-battle.md @@ -0,0 +1,5 @@ +--- +"openapi-react-query": minor +--- + +Add typesafe setQueryData to OpenapiQueryClient From 731efc0903a5a586b211d6f6a3c540b86c290a35 Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Thu, 4 Sep 2025 07:44:30 +0200 Subject: [PATCH 5/8] fix: lint issues introduced by setquerydata --- packages/openapi-react-query/biome.json | 2 +- .../examples/setquerydata-example/demo.ts | 6 ++++-- packages/openapi-react-query/src/index.ts | 2 +- .../openapi-react-query/test/index.test.tsx | 19 ++++++++++++------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/openapi-react-query/biome.json b/packages/openapi-react-query/biome.json index d5bf28ca0..f809ca5ef 100644 --- a/packages/openapi-react-query/biome.json +++ b/packages/openapi-react-query/biome.json @@ -2,7 +2,7 @@ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "extends": ["../../biome.json"], "files": { - "ignore": ["./test/fixtures/"] + "ignore": ["./test/fixtures/", "./examples/setquerydata-example/api.d.ts"] }, "linter": { "rules": { diff --git a/packages/openapi-react-query/examples/setquerydata-example/demo.ts b/packages/openapi-react-query/examples/setquerydata-example/demo.ts index 66dfe55ea..4b408918f 100644 --- a/packages/openapi-react-query/examples/setquerydata-example/demo.ts +++ b/packages/openapi-react-query/examples/setquerydata-example/demo.ts @@ -29,7 +29,7 @@ async function createPostAndUpdateCache() { "get", "/posts", (oldPosts) => { - return oldPosts ? [...oldPosts, newPost] : [newPost]; + return oldPosts ? [...oldPosts, newPost] : [newPost]; }, queryClient, {}, @@ -47,7 +47,9 @@ async function updatePostAndUpdateCache(postId: string, updates: { title?: strin (oldPost) => { // TypeScript ensures oldPost is Post | undefined // and we must return Post - if (!oldPost) throw new Error("No post in cache"); + if (!oldPost) { + throw new Error("No post in cache"); + } return { ...oldPost, ...updates }; }, queryClient, diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 3ce88ca4b..a9dd40a7c 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -301,7 +301,7 @@ export default function createClient { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); - // This should cause a TypeScript error because the return type is not the same as the expected type - // @ts-expect-error - client.setQueryData("get", "/blogposts/{post_id}", () => { - return { invalidField: "invalid" }; // This should error - }, queryClient, { - params: { path: { post_id: "1" } } - }); + client.setQueryData( + "get", + "/blogposts/{post_id}", + // @ts-expect-error - Return type is not the same as the expected type. + () => { + return { invalidField: "invalid" }; + }, + queryClient, + { + params: { path: { post_id: "1" } }, + }, + ); }); }); }); From f358836161b87cc17c736d738c123ca04ba28fbb Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Thu, 4 Sep 2025 08:26:41 +0200 Subject: [PATCH 6/8] fix: type updater function with updater type of react-query --- .../examples/setquerydata-example/README.md | 73 +------------------ .../examples/setquerydata-example/demo.ts | 31 +++----- packages/openapi-react-query/src/index.ts | 14 ++-- .../openapi-react-query/test/index.test.tsx | 39 +++++++++- 4 files changed, 58 insertions(+), 99 deletions(-) diff --git a/packages/openapi-react-query/examples/setquerydata-example/README.md b/packages/openapi-react-query/examples/setquerydata-example/README.md index ef77276e2..34f8cf570 100644 --- a/packages/openapi-react-query/examples/setquerydata-example/README.md +++ b/packages/openapi-react-query/examples/setquerydata-example/README.md @@ -14,79 +14,8 @@ The `setQueryData` method allows you to update cached query data with full TypeS npm run generate-types ``` -2. Run TypeScript check to see type safety in action: +1. Run TypeScript check to see type safety in action: ```bash npm run type-check ``` - -## Examples - -### 1. Update posts list after creating a new post - -```typescript -// After creating a post via POST /posts, update the GET /posts cache -$api.setQueryData( - "get", - "/posts", - (oldPosts) => { - // TypeScript ensures oldPosts is Post[] | undefined - // and we must return Post[] - const newPost = { id: "123", title: "New Post", content: "Content" }; - return oldPosts ? [...oldPosts, newPost] : [newPost]; - }, - queryClient, -); -``` - -### 2. Update a single post after editing - -```typescript -// After updating a post, update the specific post cache -$api.setQueryData( - "get", - "/posts/{id}", - (oldPost) => { - // TypeScript ensures oldPost is Post | undefined - // and we must return Post - if (!oldPost) return oldPost; - return { ...oldPost, title: "Updated Title" }; - }, - queryClient, - { params: { path: { id: "123" } } }, -); -``` - -### 3. Clear cache - -```typescript -// Clear the posts cache -$api.setQueryData( - "get", - "/posts", - () => [], // Must return Post[] - queryClient, -); -``` - -## Type Safety - -The `setQueryData` method enforces type safety: - -- ✅ **Valid**: Returning the correct type -- ❌ **Invalid**: Returning a different type (TypeScript error) - -See `demo-invalid.ts` for examples of invalid usage that will cause TypeScript errors. - -## Running the Example - -```bash -# Generate types -npm run generate-types - -# Check types (should pass) -npm run type-check - -# Check invalid examples (will show type errors) -npx tsc --noEmit demo-invalid.ts -``` diff --git a/packages/openapi-react-query/examples/setquerydata-example/demo.ts b/packages/openapi-react-query/examples/setquerydata-example/demo.ts index 4b408918f..0516a406c 100644 --- a/packages/openapi-react-query/examples/setquerydata-example/demo.ts +++ b/packages/openapi-react-query/examples/setquerydata-example/demo.ts @@ -14,17 +14,16 @@ const fetchClient = createFetchClient({ const $api = createClient(fetchClient); const queryClient = new QueryClient(); -// Example 1: Update posts list after creating a new post -async function createPostAndUpdateCache() { - // Simulate creating a new post - const newPost: Post = { - id: "123", - title: "New Post", - content: "Post content", - createdAt: new Date().toISOString(), - }; +// Simulate creating a new post +const newPost: Post = { + id: "123", + title: "New Post", + content: "Post content", + createdAt: new Date().toISOString(), +}; - // Update the posts list cache with the new post +// Example 1: Update posts list with updater function +async function createPostAndUpdateCache() { $api.setQueryData( "get", "/posts", @@ -40,7 +39,7 @@ async function createPostAndUpdateCache() { // Example 2: Update a single post after editing async function updatePostAndUpdateCache(postId: string, updates: { title?: string; content?: string }) { - // Update the specific post cache + // Update the specific post cache with updater function $api.setQueryData( "get", "/posts/{id}", @@ -57,15 +56,9 @@ async function updatePostAndUpdateCache(postId: string, updates: { title?: strin ); } -// Example 3: Clear posts cache +// Example 3: Directly set the data function clearPostsCache() { - $api.setQueryData( - "get", - "/posts", - () => [], // Return empty array - queryClient, - {}, - ); + $api.setQueryData("get", "/posts", [newPost], queryClient, {}); } export { createPostAndUpdateCache, updatePostAndUpdateCache, clearPostsCache }; diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index a9dd40a7c..2f841bb9f 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -11,6 +11,7 @@ import { type QueryClient, type QueryFunctionContext, type SkipToken, + type Updater, useMutation, useQuery, useSuspenseQuery, @@ -171,9 +172,10 @@ export type SetQueryDataMethod( method: Method, path: Path, - updater: ( - oldData: Required>["data"] | undefined, - ) => Required>["data"], + updater: Updater< + Required>["data"] | undefined, + Required>["data"] | undefined + >, queryClient: QueryClient, init?: Init, ) => void; @@ -294,15 +296,15 @@ export default function createClient( + setQueryData( method: Method, path: Path, - updater: (oldData: any) => any, + updater: Updater, queryClient: QueryClient, init?: Init, ) { const queryKey = init === undefined ? [method, path] : [method, path, init]; - queryClient.setQueryData(queryKey, updater as any); + queryClient.setQueryData(queryKey, updater); }, }; } diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 52310df82..e15f82918 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -125,7 +125,10 @@ describe("client", () => { }); it("returns query options that can be passed to useQueries", async () => { - const fetchClient = createFetchClient({ baseUrl, fetch: fetchInfinite }); + const fetchClient = createFetchClient({ + baseUrl, + fetch: fetchInfinite, + }); const client = createClient(fetchClient); const { result } = renderHook( @@ -1294,7 +1297,7 @@ describe("client", () => { ); // Update data using setQueryData with custom client - client.setQueryData("get", "/blogposts/{post_id}", (oldData) => updatedData, customQueryClient, { + client.setQueryData("get", "/blogposts/{post_id}", () => updatedData, customQueryClient, { params: { path: { post_id: "1" } }, }); @@ -1317,6 +1320,38 @@ describe("client", () => { expect(mainCachedData).toBeUndefined(); }); + it("should allow setting data directly (not just via updater function)", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = ["item1", "item2"]; + const newData = ["updated1", "updated2"]; + + // Set initial data + queryClient.setQueryData(client.queryOptions("get", "/string-array").queryKey, initialData); + + // Set data directly using setQueryData (not a function) + client.setQueryData("get", "/string-array", newData, queryClient); + + // Verify data was updated + const cachedData = queryClient.getQueryData(client.queryOptions("get", "/string-array").queryKey); + expect(cachedData).toEqual(newData); + }); + + it("should error if you set data with the wrong type directly", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + // @ts-expect-error - should not allow setting a string for a string array query. + client.setQueryData("get", "/string-array", "not an array", queryClient, { + params: { path: { post_id: "1" } }, + }); + + // @ts-expect-error - should not allow setting an object with the wrong type for a string array query. + client.setQueryData("get", "/string-array", { wrong: "data" }, queryClient, { + params: { path: { post_id: "1" } }, + }); + }); + it("should enforce type safety on updater function return type", () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); From d1b5efa2f55983acad60c28babbb9393398c4c87 Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Thu, 4 Sep 2025 09:08:49 +0200 Subject: [PATCH 7/8] fix: init should be typed and required when path needs it --- .../examples/setquerydata-example/demo.ts | 6 ++++-- packages/openapi-react-query/src/index.ts | 15 ++++++--------- packages/openapi-react-query/test/index.test.tsx | 9 +++++++++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/openapi-react-query/examples/setquerydata-example/demo.ts b/packages/openapi-react-query/examples/setquerydata-example/demo.ts index 0516a406c..6491c5ffc 100644 --- a/packages/openapi-react-query/examples/setquerydata-example/demo.ts +++ b/packages/openapi-react-query/examples/setquerydata-example/demo.ts @@ -40,12 +40,11 @@ async function createPostAndUpdateCache() { // Example 2: Update a single post after editing async function updatePostAndUpdateCache(postId: string, updates: { title?: string; content?: string }) { // Update the specific post cache with updater function + // This should now error: missing required init (id) $api.setQueryData( "get", "/posts/{id}", (oldPost) => { - // TypeScript ensures oldPost is Post | undefined - // and we must return Post if (!oldPost) { throw new Error("No post in cache"); } @@ -54,6 +53,9 @@ async function updatePostAndUpdateCache(postId: string, updates: { title?: strin queryClient, { params: { path: { id: postId } } }, ); + + // This should be fine: /posts does not require init + $api.setQueryData("get", "/posts", (oldPosts) => oldPosts || [], queryClient); } // Example 3: Directly set the data diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 2f841bb9f..e500f6977 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -169,15 +169,13 @@ export type SetQueryDataMethod, Init extends MaybeOptionalInit, + Data extends Required>["data"], >( method: Method, path: Path, - updater: Updater< - Required>["data"] | undefined, - Required>["data"] | undefined - >, + updater: Updater, queryClient: QueryClient, - init?: Init, + ...init: RequiredKeysOf extends never ? [InitWithUnknowns?] : [InitWithUnknowns] ) => void; export interface OpenapiQueryClient { @@ -296,15 +294,14 @@ export default function createClient( + setQueryData( method: Method, path: Path, updater: Updater, queryClient: QueryClient, - init?: Init, + ...init: RequiredKeysOf extends never ? [InitWithUnknowns?] : [InitWithUnknowns] ) { - const queryKey = init === undefined ? [method, path] : [method, path, init]; - queryClient.setQueryData(queryKey, updater); + queryClient.setQueryData([method, path, init], updater); }, }; } diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index e15f82918..dac23a4f3 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -1280,6 +1280,15 @@ describe("client", () => { expect(cachedData).toEqual(updatedData); }); + it("should error if you don't pass init params when required", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + // For /blogposts/{post_id}, init params are required (post_id in path) + // @ts-expect-error - should error because init params are required + client.setQueryData("get", "/blogposts/{post_id}", (oldData) => oldData, queryClient); + }); + it("should use provided custom queryClient", async () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); From e8a2f2f0134004fe0afa11546826a381ddc7069f Mon Sep 17 00:00:00 2001 From: Pieter van der Werk Date: Thu, 4 Sep 2025 14:26:53 +0200 Subject: [PATCH 8/8] fix: query key building --- packages/openapi-react-query/src/index.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index e500f6977..e1931cc25 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -288,20 +288,8 @@ export default function createClient( - method: Method, - path: Path, - updater: Updater, - queryClient: QueryClient, - ...init: RequiredKeysOf extends never ? [InitWithUnknowns?] : [InitWithUnknowns] - ) { - queryClient.setQueryData([method, path, init], updater); + setQueryData(method, path, updater, queryClient, ...init) { + queryClient.setQueryData(init === undefined ? [method, path] : [method, path, ...init], updater); }, }; }