From d7e77c6689daadca94ccdb79d02abc30a3b976e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 11:51:17 +0000 Subject: [PATCH 1/3] perf(plugin-react-query): dispatch operations to one matching generator family Classifies each operation as query or mutation once via a new operationGenerator, then calls only the matching hook generators, instead of registering all five (query, suspenseQuery, infiniteQuery, suspenseInfiniteQuery, mutation) as separate core generators that each run for every operation with four returning early. Fixes kubb-labs/kubb#3816. Generated output is unchanged (snapshot parity verified via tests/3.0.x/pluginReactQuery.test.ts). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LnAWjXX2zoWWScgn7NsLaf --- .changeset/react-query-operation-dispatch.md | 5 + .../src/generators/index.ts | 1 + .../generators/operationGenerator.test.tsx | 174 ++++++++++++++++++ .../src/generators/operationGenerator.tsx | 38 ++++ packages/plugin-react-query/src/plugin.ts | 20 +- 5 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 .changeset/react-query-operation-dispatch.md create mode 100644 packages/plugin-react-query/src/generators/operationGenerator.test.tsx create mode 100644 packages/plugin-react-query/src/generators/operationGenerator.tsx diff --git a/.changeset/react-query-operation-dispatch.md b/.changeset/react-query-operation-dispatch.md new file mode 100644 index 000000000..64808e337 --- /dev/null +++ b/.changeset/react-query-operation-dispatch.md @@ -0,0 +1,5 @@ +--- +"@kubb/plugin-react-query": patch +--- + +Classify each operation as query or mutation once and dispatch only to the matching hook generators, instead of running all five (query, suspenseQuery, infiniteQuery, suspenseInfiniteQuery, mutation) for every operation with four returning early. Fixes kubb-labs/kubb#3816. Generated output is unchanged. diff --git a/packages/plugin-react-query/src/generators/index.ts b/packages/plugin-react-query/src/generators/index.ts index c776e9f4f..f70333f9a 100644 --- a/packages/plugin-react-query/src/generators/index.ts +++ b/packages/plugin-react-query/src/generators/index.ts @@ -2,6 +2,7 @@ export { customHookOptionsFileGenerator } from './customHookOptionsFileGenerator export { hookOptionsGenerator } from './hookOptionsGenerator.tsx' export { infiniteQueryGenerator } from './infiniteQueryGenerator.tsx' export { mutationGenerator } from './mutationGenerator.tsx' +export { operationGenerator } from './operationGenerator.tsx' export { queryGenerator } from './queryGenerator.tsx' export { suspenseInfiniteQueryGenerator } from './suspenseInfiniteQueryGenerator.tsx' export { suspenseQueryGenerator } from './suspenseQueryGenerator.tsx' diff --git a/packages/plugin-react-query/src/generators/operationGenerator.test.tsx b/packages/plugin-react-query/src/generators/operationGenerator.test.tsx new file mode 100644 index 000000000..1d98b6833 --- /dev/null +++ b/packages/plugin-react-query/src/generators/operationGenerator.test.tsx @@ -0,0 +1,174 @@ +import type { Config } from 'kubb/kit' +import { ast, memoryStorage } from 'kubb/kit' +import { createMockedAdapter, createMockedPlugin, createMockedPluginDriver, renderGeneratorOperation } from 'kubb/kit/testing' +import type { PluginTs } from '@kubb/plugin-ts' +import { resolverTs } from '@kubb/plugin-ts' +import { resolverClient } from '@internals/client' +import { describe, expect, test, vi } from 'vitest' +import { mutationKeyTransformer, queryKeyTransformer } from '@internals/tanstack-query' +import { resolverReactQuery } from '../resolvers/resolverReactQuery.ts' +import type { PluginReactQuery } from '../types.ts' +import { mutationGenerator } from './mutationGenerator.tsx' +import { operationGenerator } from './operationGenerator.tsx' +import { queryGenerator } from './queryGenerator.tsx' + +const testConfig: Config = { + root: '.', + input: {}, + output: { path: 'test' }, + plugins: [], + parsers: [], + reporters: [], + adapter: createMockedAdapter(), + storage: memoryStorage(), +} + +const defaultOptions: PluginReactQuery['resolvedOptions'] = { + client: { kind: 'contract', pluginName: 'plugin-axios' }, + queryKey: queryKeyTransformer, + mutationKey: mutationKeyTransformer, + query: { + importPath: '@tanstack/react-query', + methods: ['GET'], + }, + mutation: { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + importPath: '@tanstack/react-query', + }, + suspense: false, + infinite: false, + customOptions: null, + hooks: true, + exclude: [], + include: undefined, + override: [], + output: { path: '.', mode: 'directory' }, + group: null, + resolver: resolverReactQuery, +} + +const mockedTsPlugin = createMockedPlugin({ + name: 'plugin-ts', + options: { output: { path: '.', mode: 'directory' }, group: null } as PluginTs['resolvedOptions'], + resolver: resolverTs, +}) + +const mockedAxiosPlugin = createMockedPlugin({ + name: 'plugin-axios', + options: { output: { path: './clients', mode: 'directory' }, group: null } as PluginTs['resolvedOptions'], + resolver: resolverClient, +}) + +// The generator looks plugins up by name: plugin-ts for the request types, plugin-axios for the +// contract . The built-in mock is name-agnostic, so dispatch on the name here. +function createMultiPluginDriver(name: string) { + const driver = createMockedPluginDriver({ + name, + plugin: mockedTsPlugin as unknown as NonNullable[0]>['plugin'], + }) + const byName = { 'plugin-ts': mockedTsPlugin, 'plugin-axios': mockedAxiosPlugin } as Record + return { + ...driver, + getPlugin: (pluginName: string) => byName[pluginName] ?? mockedTsPlugin, + getResolver: (pluginName: string) => byName[pluginName]?.resolver ?? resolverTs, + } as typeof driver +} + +const getPetByIdNode = ast.factory.createOperation({ + operationId: 'getPetById', + method: 'GET', + path: '/pet/{petId}', + tags: ['pet'], + parameters: [ + ast.factory.createParameter({ name: 'petId', in: 'path', schema: ast.factory.createSchema({ type: 'string' }), required: true }), + ast.factory.createParameter({ name: 'page', in: 'query', schema: ast.factory.createSchema({ type: 'string' }) }), + ], + responses: [ + ast.factory.createResponse({ + statusCode: '200', + schema: ast.factory.createSchema({ type: 'object', properties: [] }), + description: 'successful operation', + }), + ], +}) + +const updatePetByIdNode = ast.factory.createOperation({ + operationId: 'updatePetWithForm', + method: 'POST', + path: '/pet/{petId}', + tags: ['pet'], + parameters: [ast.factory.createParameter({ name: 'petId', in: 'path', schema: ast.factory.createSchema({ type: 'string' }), required: true })], + requestBody: { + content: [ast.factory.createContent({ contentType: 'application/json', schema: ast.factory.createSchema({ type: 'object', properties: [] }) })], + }, + responses: [ + ast.factory.createResponse({ + statusCode: '200', + schema: ast.factory.createSchema({ type: 'object', properties: [] }), + description: 'successful operation', + }), + ], +}) + +async function runOperationGenerator(node: ast.OperationNode, options: PluginReactQuery['resolvedOptions'], driverName: string) { + const plugin = createMockedPlugin({ name: 'plugin-react-query', options, resolver: resolverReactQuery }) + const driver = createMultiPluginDriver(driverName) + + await renderGeneratorOperation(operationGenerator, node, { + config: testConfig, + adapter: createMockedAdapter(), + driver, + plugin, + options, + resolver: resolverReactQuery, + }) + + return driver.fileManager.files +} + +describe('operationGenerator operation', () => { + test('emits only the mutation output for a mutation operation, even with suspense and infinite enabled', async () => { + const options: PluginReactQuery['resolvedOptions'] = { ...defaultOptions, suspense: {}, infinite: { queryParam: 'page' } } + + const files = await runOperationGenerator(updatePetByIdNode, options, 'mutationOnly') + + expect(files.length).toBe(1) + expect(files[0]?.baseName).toBe('useUpdatePetWithForm.ts') + }) + + test('emits every enabled query-family file for a query operation, in one dispatch', async () => { + const options: PluginReactQuery['resolvedOptions'] = { ...defaultOptions, suspense: {}, infinite: { queryParam: 'page' } } + + const files = await runOperationGenerator(getPetByIdNode, options, 'queryFamily') + const baseNames = files.map((file) => file.baseName).sort() + + expect(baseNames).toStrictEqual(['useGetPetById.ts', 'useGetPetByIdSuspense.ts', 'useGetPetByIdInfinite.ts', 'useGetPetByIdSuspenseInfinite.ts'].sort()) + }) + + test('emits only the base query file when suspense and infinite are disabled', async () => { + const files = await runOperationGenerator(getPetByIdNode, defaultOptions, 'queryDefault') + + expect(files.length).toBe(1) + expect(files[0]?.baseName).toBe('useGetPetById.ts') + }) + + test('calls only the mutation-family generator for a mutation operation', async () => { + using querySpy = vi.spyOn(queryGenerator, 'operation') + using mutationSpy = vi.spyOn(mutationGenerator, 'operation') + + await runOperationGenerator(updatePetByIdNode, defaultOptions, 'spyMutation') + + expect(mutationSpy).toHaveBeenCalledOnce() + expect(querySpy).not.toHaveBeenCalled() + }) + + test('calls only the query-family generators for a query operation', async () => { + using querySpy = vi.spyOn(queryGenerator, 'operation') + using mutationSpy = vi.spyOn(mutationGenerator, 'operation') + + await runOperationGenerator(getPetByIdNode, defaultOptions, 'spyQuery') + + expect(querySpy).toHaveBeenCalledOnce() + expect(mutationSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/plugin-react-query/src/generators/operationGenerator.tsx b/packages/plugin-react-query/src/generators/operationGenerator.tsx new file mode 100644 index 000000000..60de0493f --- /dev/null +++ b/packages/plugin-react-query/src/generators/operationGenerator.tsx @@ -0,0 +1,38 @@ +import { ast, defineGenerator } from 'kubb/kit' +import { jsxRenderer } from 'kubb/jsx' +import { classifyOperation } from '../utils.ts' +import { infiniteQueryGenerator } from './infiniteQueryGenerator.tsx' +import { mutationGenerator } from './mutationGenerator.tsx' +import { queryGenerator } from './queryGenerator.tsx' +import { suspenseInfiniteQueryGenerator } from './suspenseInfiniteQueryGenerator.tsx' +import { suspenseQueryGenerator } from './suspenseQueryGenerator.tsx' +import type { PluginReactQuery } from '../types' + +const queryGenerators = [queryGenerator, suspenseQueryGenerator, infiniteQueryGenerator, suspenseInfiniteQueryGenerator] +const mutationGenerators = [mutationGenerator] + +/** + * Classifies each operation once and dispatches only to the generators for its family + * (query or mutation), instead of running all five hook generators — query, suspenseQuery, + * infiniteQuery, suspenseInfiniteQuery, mutation — per operation, where four return early. + */ +export const operationGenerator = defineGenerator({ + name: 'react-query-operation', + renderer: jsxRenderer, + async operation(node, ctx) { + if (!ast.isHttpOperationNode(node)) return null + const { query, mutation } = ctx.options + + const { isQuery, isMutation } = classifyOperation(node, { query, mutation }) + const generators = isMutation ? mutationGenerators : isQuery ? queryGenerators : [] + if (!generators.length) return null + + const elements = [] + for (const generator of generators) { + const element = await generator.operation!(node, ctx) + if (element) elements.push(element) + } + + return elements.length ? <>{elements} : null + }, +}) diff --git a/packages/plugin-react-query/src/plugin.ts b/packages/plugin-react-query/src/plugin.ts index 29198fe5a..d2d8cb261 100644 --- a/packages/plugin-react-query/src/plugin.ts +++ b/packages/plugin-react-query/src/plugin.ts @@ -3,15 +3,7 @@ import { definePlugin, Resolver } from 'kubb/kit' import { resolveContractClient } from '@internals/client' import { pluginTsName } from '@kubb/plugin-ts' import { mutationKeyTransformer, queryKeyTransformer, resolveInfiniteConfig, resolveMutationConfig, resolveQueryConfig } from '@internals/tanstack-query' -import { - customHookOptionsFileGenerator, - hookOptionsGenerator, - infiniteQueryGenerator, - mutationGenerator, - queryGenerator, - suspenseInfiniteQueryGenerator, - suspenseQueryGenerator, -} from './generators' +import { customHookOptionsFileGenerator, hookOptionsGenerator, operationGenerator } from './generators' import { resolverReactQuery } from './resolvers/resolverReactQuery.ts' import type { PluginReactQuery, ResolverReactQuery } from './types.ts' @@ -66,15 +58,7 @@ export const pluginReactQuery = definePlugin((options) => { macros: userMacros, } = options - const selectedGenerators = [ - queryGenerator, - suspenseQueryGenerator, - infiniteQueryGenerator, - suspenseInfiniteQueryGenerator, - mutationGenerator, - hookOptionsGenerator, - customHookOptionsFileGenerator, - ] + const selectedGenerators = [operationGenerator, hookOptionsGenerator, customHookOptionsFileGenerator] const groupConfig = createGroupConfig(group) From 6f4da2942f6a758cc937404b2ea9ef22d97aa043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:07:44 +0000 Subject: [PATCH 2/3] perf(plugin-react-query): inline the operation dispatcher into plugin.ts Fold the query/mutation classification and dispatch directly into plugin.ts instead of a separate generator file, so this PR doesn't add net-new files on top of the five existing generators. Each matching generator's JSX result is rendered and upserted directly (no `renderer` needed on the dispatcher), so plugin.ts stays plain TypeScript like every other plugin's entry file. Coverage now relies on the existing per-generator unit tests plus the tests/3.0.x/pluginReactQuery.test.ts snapshot suite, which still passes unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LnAWjXX2zoWWScgn7NsLaf --- .../src/generators/index.ts | 1 - .../generators/operationGenerator.test.tsx | 174 ------------------ .../src/generators/operationGenerator.tsx | 38 ---- packages/plugin-react-query/src/plugin.ts | 54 +++++- 4 files changed, 51 insertions(+), 216 deletions(-) delete mode 100644 packages/plugin-react-query/src/generators/operationGenerator.test.tsx delete mode 100644 packages/plugin-react-query/src/generators/operationGenerator.tsx diff --git a/packages/plugin-react-query/src/generators/index.ts b/packages/plugin-react-query/src/generators/index.ts index f70333f9a..c776e9f4f 100644 --- a/packages/plugin-react-query/src/generators/index.ts +++ b/packages/plugin-react-query/src/generators/index.ts @@ -2,7 +2,6 @@ export { customHookOptionsFileGenerator } from './customHookOptionsFileGenerator export { hookOptionsGenerator } from './hookOptionsGenerator.tsx' export { infiniteQueryGenerator } from './infiniteQueryGenerator.tsx' export { mutationGenerator } from './mutationGenerator.tsx' -export { operationGenerator } from './operationGenerator.tsx' export { queryGenerator } from './queryGenerator.tsx' export { suspenseInfiniteQueryGenerator } from './suspenseInfiniteQueryGenerator.tsx' export { suspenseQueryGenerator } from './suspenseQueryGenerator.tsx' diff --git a/packages/plugin-react-query/src/generators/operationGenerator.test.tsx b/packages/plugin-react-query/src/generators/operationGenerator.test.tsx deleted file mode 100644 index 1d98b6833..000000000 --- a/packages/plugin-react-query/src/generators/operationGenerator.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import type { Config } from 'kubb/kit' -import { ast, memoryStorage } from 'kubb/kit' -import { createMockedAdapter, createMockedPlugin, createMockedPluginDriver, renderGeneratorOperation } from 'kubb/kit/testing' -import type { PluginTs } from '@kubb/plugin-ts' -import { resolverTs } from '@kubb/plugin-ts' -import { resolverClient } from '@internals/client' -import { describe, expect, test, vi } from 'vitest' -import { mutationKeyTransformer, queryKeyTransformer } from '@internals/tanstack-query' -import { resolverReactQuery } from '../resolvers/resolverReactQuery.ts' -import type { PluginReactQuery } from '../types.ts' -import { mutationGenerator } from './mutationGenerator.tsx' -import { operationGenerator } from './operationGenerator.tsx' -import { queryGenerator } from './queryGenerator.tsx' - -const testConfig: Config = { - root: '.', - input: {}, - output: { path: 'test' }, - plugins: [], - parsers: [], - reporters: [], - adapter: createMockedAdapter(), - storage: memoryStorage(), -} - -const defaultOptions: PluginReactQuery['resolvedOptions'] = { - client: { kind: 'contract', pluginName: 'plugin-axios' }, - queryKey: queryKeyTransformer, - mutationKey: mutationKeyTransformer, - query: { - importPath: '@tanstack/react-query', - methods: ['GET'], - }, - mutation: { - methods: ['POST', 'PUT', 'PATCH', 'DELETE'], - importPath: '@tanstack/react-query', - }, - suspense: false, - infinite: false, - customOptions: null, - hooks: true, - exclude: [], - include: undefined, - override: [], - output: { path: '.', mode: 'directory' }, - group: null, - resolver: resolverReactQuery, -} - -const mockedTsPlugin = createMockedPlugin({ - name: 'plugin-ts', - options: { output: { path: '.', mode: 'directory' }, group: null } as PluginTs['resolvedOptions'], - resolver: resolverTs, -}) - -const mockedAxiosPlugin = createMockedPlugin({ - name: 'plugin-axios', - options: { output: { path: './clients', mode: 'directory' }, group: null } as PluginTs['resolvedOptions'], - resolver: resolverClient, -}) - -// The generator looks plugins up by name: plugin-ts for the request types, plugin-axios for the -// contract . The built-in mock is name-agnostic, so dispatch on the name here. -function createMultiPluginDriver(name: string) { - const driver = createMockedPluginDriver({ - name, - plugin: mockedTsPlugin as unknown as NonNullable[0]>['plugin'], - }) - const byName = { 'plugin-ts': mockedTsPlugin, 'plugin-axios': mockedAxiosPlugin } as Record - return { - ...driver, - getPlugin: (pluginName: string) => byName[pluginName] ?? mockedTsPlugin, - getResolver: (pluginName: string) => byName[pluginName]?.resolver ?? resolverTs, - } as typeof driver -} - -const getPetByIdNode = ast.factory.createOperation({ - operationId: 'getPetById', - method: 'GET', - path: '/pet/{petId}', - tags: ['pet'], - parameters: [ - ast.factory.createParameter({ name: 'petId', in: 'path', schema: ast.factory.createSchema({ type: 'string' }), required: true }), - ast.factory.createParameter({ name: 'page', in: 'query', schema: ast.factory.createSchema({ type: 'string' }) }), - ], - responses: [ - ast.factory.createResponse({ - statusCode: '200', - schema: ast.factory.createSchema({ type: 'object', properties: [] }), - description: 'successful operation', - }), - ], -}) - -const updatePetByIdNode = ast.factory.createOperation({ - operationId: 'updatePetWithForm', - method: 'POST', - path: '/pet/{petId}', - tags: ['pet'], - parameters: [ast.factory.createParameter({ name: 'petId', in: 'path', schema: ast.factory.createSchema({ type: 'string' }), required: true })], - requestBody: { - content: [ast.factory.createContent({ contentType: 'application/json', schema: ast.factory.createSchema({ type: 'object', properties: [] }) })], - }, - responses: [ - ast.factory.createResponse({ - statusCode: '200', - schema: ast.factory.createSchema({ type: 'object', properties: [] }), - description: 'successful operation', - }), - ], -}) - -async function runOperationGenerator(node: ast.OperationNode, options: PluginReactQuery['resolvedOptions'], driverName: string) { - const plugin = createMockedPlugin({ name: 'plugin-react-query', options, resolver: resolverReactQuery }) - const driver = createMultiPluginDriver(driverName) - - await renderGeneratorOperation(operationGenerator, node, { - config: testConfig, - adapter: createMockedAdapter(), - driver, - plugin, - options, - resolver: resolverReactQuery, - }) - - return driver.fileManager.files -} - -describe('operationGenerator operation', () => { - test('emits only the mutation output for a mutation operation, even with suspense and infinite enabled', async () => { - const options: PluginReactQuery['resolvedOptions'] = { ...defaultOptions, suspense: {}, infinite: { queryParam: 'page' } } - - const files = await runOperationGenerator(updatePetByIdNode, options, 'mutationOnly') - - expect(files.length).toBe(1) - expect(files[0]?.baseName).toBe('useUpdatePetWithForm.ts') - }) - - test('emits every enabled query-family file for a query operation, in one dispatch', async () => { - const options: PluginReactQuery['resolvedOptions'] = { ...defaultOptions, suspense: {}, infinite: { queryParam: 'page' } } - - const files = await runOperationGenerator(getPetByIdNode, options, 'queryFamily') - const baseNames = files.map((file) => file.baseName).sort() - - expect(baseNames).toStrictEqual(['useGetPetById.ts', 'useGetPetByIdSuspense.ts', 'useGetPetByIdInfinite.ts', 'useGetPetByIdSuspenseInfinite.ts'].sort()) - }) - - test('emits only the base query file when suspense and infinite are disabled', async () => { - const files = await runOperationGenerator(getPetByIdNode, defaultOptions, 'queryDefault') - - expect(files.length).toBe(1) - expect(files[0]?.baseName).toBe('useGetPetById.ts') - }) - - test('calls only the mutation-family generator for a mutation operation', async () => { - using querySpy = vi.spyOn(queryGenerator, 'operation') - using mutationSpy = vi.spyOn(mutationGenerator, 'operation') - - await runOperationGenerator(updatePetByIdNode, defaultOptions, 'spyMutation') - - expect(mutationSpy).toHaveBeenCalledOnce() - expect(querySpy).not.toHaveBeenCalled() - }) - - test('calls only the query-family generators for a query operation', async () => { - using querySpy = vi.spyOn(queryGenerator, 'operation') - using mutationSpy = vi.spyOn(mutationGenerator, 'operation') - - await runOperationGenerator(getPetByIdNode, defaultOptions, 'spyQuery') - - expect(querySpy).toHaveBeenCalledOnce() - expect(mutationSpy).not.toHaveBeenCalled() - }) -}) diff --git a/packages/plugin-react-query/src/generators/operationGenerator.tsx b/packages/plugin-react-query/src/generators/operationGenerator.tsx deleted file mode 100644 index 60de0493f..000000000 --- a/packages/plugin-react-query/src/generators/operationGenerator.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ast, defineGenerator } from 'kubb/kit' -import { jsxRenderer } from 'kubb/jsx' -import { classifyOperation } from '../utils.ts' -import { infiniteQueryGenerator } from './infiniteQueryGenerator.tsx' -import { mutationGenerator } from './mutationGenerator.tsx' -import { queryGenerator } from './queryGenerator.tsx' -import { suspenseInfiniteQueryGenerator } from './suspenseInfiniteQueryGenerator.tsx' -import { suspenseQueryGenerator } from './suspenseQueryGenerator.tsx' -import type { PluginReactQuery } from '../types' - -const queryGenerators = [queryGenerator, suspenseQueryGenerator, infiniteQueryGenerator, suspenseInfiniteQueryGenerator] -const mutationGenerators = [mutationGenerator] - -/** - * Classifies each operation once and dispatches only to the generators for its family - * (query or mutation), instead of running all five hook generators — query, suspenseQuery, - * infiniteQuery, suspenseInfiniteQuery, mutation — per operation, where four return early. - */ -export const operationGenerator = defineGenerator({ - name: 'react-query-operation', - renderer: jsxRenderer, - async operation(node, ctx) { - if (!ast.isHttpOperationNode(node)) return null - const { query, mutation } = ctx.options - - const { isQuery, isMutation } = classifyOperation(node, { query, mutation }) - const generators = isMutation ? mutationGenerators : isQuery ? queryGenerators : [] - if (!generators.length) return null - - const elements = [] - for (const generator of generators) { - const element = await generator.operation!(node, ctx) - if (element) elements.push(element) - } - - return elements.length ? <>{elements} : null - }, -}) diff --git a/packages/plugin-react-query/src/plugin.ts b/packages/plugin-react-query/src/plugin.ts index d2d8cb261..c95d3e3a6 100644 --- a/packages/plugin-react-query/src/plugin.ts +++ b/packages/plugin-react-query/src/plugin.ts @@ -1,12 +1,60 @@ import { createGroupConfig } from '@internals/shared' -import { definePlugin, Resolver } from 'kubb/kit' +import { ast, defineGenerator, definePlugin, Resolver } from 'kubb/kit' +import { jsxRenderer } from 'kubb/jsx' +import type { KubbReactElement } from 'kubb/jsx' import { resolveContractClient } from '@internals/client' import { pluginTsName } from '@kubb/plugin-ts' -import { mutationKeyTransformer, queryKeyTransformer, resolveInfiniteConfig, resolveMutationConfig, resolveQueryConfig } from '@internals/tanstack-query' -import { customHookOptionsFileGenerator, hookOptionsGenerator, operationGenerator } from './generators' +import { + classifyOperation, + mutationKeyTransformer, + queryKeyTransformer, + resolveInfiniteConfig, + resolveMutationConfig, + resolveQueryConfig, +} from '@internals/tanstack-query' +import { + customHookOptionsFileGenerator, + hookOptionsGenerator, + infiniteQueryGenerator, + mutationGenerator, + queryGenerator, + suspenseInfiniteQueryGenerator, + suspenseQueryGenerator, +} from './generators' import { resolverReactQuery } from './resolvers/resolverReactQuery.ts' import type { PluginReactQuery, ResolverReactQuery } from './types.ts' +const queryFamily = [queryGenerator, suspenseQueryGenerator, infiniteQueryGenerator, suspenseInfiniteQueryGenerator] +const mutationFamily = [mutationGenerator] + +/** + * Classifies each operation as query or mutation once, then renders only the generators for that + * family instead of running all five (query, suspenseQuery, infiniteQuery, suspenseInfiniteQuery, + * mutation) per operation, where four would return early. No `renderer` is set: each matching + * generator's JSX result is rendered and upserted directly, so this stays plain TypeScript. + */ +const operationGenerator = defineGenerator({ + name: 'react-query-operation', + async operation(node, ctx) { + if (!ast.isHttpOperationNode(node)) return null + const { query, mutation } = ctx.options + + const { isQuery, isMutation } = classifyOperation(node, { query, mutation }) + const family = isMutation ? mutationFamily : isQuery ? queryFamily : [] + + for (const generator of family) { + const element = await generator.operation!(node, ctx) + if (!element) continue + + using instance = jsxRenderer() + await instance.render(element as KubbReactElement) + await ctx.upsertFile(...instance.files) + } + + return null + }, +}) + /** * Canonical plugin name for `@kubb/plugin-react-query`. Used for driver lookups * and cross-plugin dependency references. From 1b86e092b0174dcb538c6ea0001759e8a30f558b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:14:21 +0000 Subject: [PATCH 3/3] refactor(plugin-react-query): move operation dispatcher to @internals/tanstack-query Move the query/mutation classify-and-dispatch generator into @internals/tanstack-query as createOperationDispatcher, next to the classifyOperation it's built on, instead of duplicating it inline in plugin-react-query's plugin.ts. Other TanStack-family plugins (vue-query, swr) have the same query/mutation config shape and can reuse it later. plugin.ts now just builds the query and mutation generator lists and passes them to the shared factory. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LnAWjXX2zoWWScgn7NsLaf --- internals/tanstack-query/src/index.ts | 1 + .../tanstack-query/src/operationDispatcher.ts | 51 +++++++++++++++++++ packages/plugin-react-query/src/plugin.ts | 37 ++------------ 3 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 internals/tanstack-query/src/operationDispatcher.ts diff --git a/internals/tanstack-query/src/index.ts b/internals/tanstack-query/src/index.ts index ab12887f7..2c42ce2e2 100644 --- a/internals/tanstack-query/src/index.ts +++ b/internals/tanstack-query/src/index.ts @@ -1,5 +1,6 @@ export { InfiniteQueryOptions } from './components/InfiniteQueryOptions.tsx' export { MutationKey, mutationKeyTransformer } from './components/MutationKey.tsx' +export { createOperationDispatcher } from './operationDispatcher.ts' export { createMutationResolver, createQueryResolver, type MutationResolver, type QueryResolver, type QueryVariant } from './resolver.ts' export { QueryKey, queryKeyTransformer } from './components/QueryKey.tsx' export type { Infinite, Mutation, ParamsCasing, ParamsType, PathParamsType, Query, Transformer } from './types.ts' diff --git a/internals/tanstack-query/src/operationDispatcher.ts b/internals/tanstack-query/src/operationDispatcher.ts new file mode 100644 index 000000000..bb409cda2 --- /dev/null +++ b/internals/tanstack-query/src/operationDispatcher.ts @@ -0,0 +1,51 @@ +import { ast, defineGenerator } from 'kubb/kit' +import { jsxRenderer } from 'kubb/jsx' +import { classifyOperation } from './utils.ts' +import type { Generator, PluginFactoryOptions } from 'kubb/kit' +import type { KubbReactElement } from 'kubb/jsx' +import type { Mutation, Query } from './types.ts' + +type OperationDispatcherResolvedOptions = { + query: Required | false + mutation: Required | false +} + +/** + * Builds a single operation generator that classifies each operation as query or mutation once, + * then renders only the generators for that family. Replaces registering every hook generator + * (query, suspenseQuery, infiniteQuery, suspenseInfiniteQuery, mutation, ...) directly with the + * core engine, which would otherwise run all of them per operation with the non-matching ones + * returning early. No `renderer` is set on the returned generator: each matching generator's + * JSX result is rendered and upserted directly, so callers stay plain TypeScript. + */ +export function createOperationDispatcher>({ + name, + queryGenerators, + mutationGenerators, +}: { + name: string + queryGenerators: Array> + mutationGenerators: Array> +}): Generator { + return defineGenerator({ + name, + async operation(node, ctx) { + if (!ast.isHttpOperationNode(node)) return null + const { query, mutation } = ctx.options + + const { isQuery, isMutation } = classifyOperation(node, { query, mutation }) + const family = isMutation ? mutationGenerators : isQuery ? queryGenerators : [] + + for (const generator of family) { + const element = await generator.operation?.(node, ctx) + if (!element) continue + + using instance = jsxRenderer() + await instance.render(element as KubbReactElement) + await ctx.upsertFile(...instance.files) + } + + return null + }, + }) +} diff --git a/packages/plugin-react-query/src/plugin.ts b/packages/plugin-react-query/src/plugin.ts index c95d3e3a6..52e35a770 100644 --- a/packages/plugin-react-query/src/plugin.ts +++ b/packages/plugin-react-query/src/plugin.ts @@ -1,11 +1,9 @@ import { createGroupConfig } from '@internals/shared' -import { ast, defineGenerator, definePlugin, Resolver } from 'kubb/kit' -import { jsxRenderer } from 'kubb/jsx' -import type { KubbReactElement } from 'kubb/jsx' +import { definePlugin, Resolver } from 'kubb/kit' import { resolveContractClient } from '@internals/client' import { pluginTsName } from '@kubb/plugin-ts' import { - classifyOperation, + createOperationDispatcher, mutationKeyTransformer, queryKeyTransformer, resolveInfiniteConfig, @@ -24,35 +22,10 @@ import { import { resolverReactQuery } from './resolvers/resolverReactQuery.ts' import type { PluginReactQuery, ResolverReactQuery } from './types.ts' -const queryFamily = [queryGenerator, suspenseQueryGenerator, infiniteQueryGenerator, suspenseInfiniteQueryGenerator] -const mutationFamily = [mutationGenerator] - -/** - * Classifies each operation as query or mutation once, then renders only the generators for that - * family instead of running all five (query, suspenseQuery, infiniteQuery, suspenseInfiniteQuery, - * mutation) per operation, where four would return early. No `renderer` is set: each matching - * generator's JSX result is rendered and upserted directly, so this stays plain TypeScript. - */ -const operationGenerator = defineGenerator({ +const operationGenerator = createOperationDispatcher({ name: 'react-query-operation', - async operation(node, ctx) { - if (!ast.isHttpOperationNode(node)) return null - const { query, mutation } = ctx.options - - const { isQuery, isMutation } = classifyOperation(node, { query, mutation }) - const family = isMutation ? mutationFamily : isQuery ? queryFamily : [] - - for (const generator of family) { - const element = await generator.operation!(node, ctx) - if (!element) continue - - using instance = jsxRenderer() - await instance.render(element as KubbReactElement) - await ctx.upsertFile(...instance.files) - } - - return null - }, + queryGenerators: [queryGenerator, suspenseQueryGenerator, infiniteQueryGenerator, suspenseInfiniteQueryGenerator], + mutationGenerators: [mutationGenerator], }) /**