diff --git a/.changeset/funny-nails-do.md b/.changeset/funny-nails-do.md new file mode 100644 index 0000000000..419b341a73 --- /dev/null +++ b/.changeset/funny-nails-do.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/typescript-react-query': minor +--- + +adds uniqueSuspenseQueryKeys config to control suspense query key generation diff --git a/packages/plugins/typescript/react-query/src/config.ts b/packages/plugins/typescript/react-query/src/config.ts index bea13d36b5..4482587ee2 100644 --- a/packages/plugins/typescript/react-query/src/config.ts +++ b/packages/plugins/typescript/react-query/src/config.ts @@ -83,6 +83,13 @@ export interface BaseReactQueryPluginConfig { */ addSuspenseQuery?: boolean; + /** + * @default true + * @description Whether suspense queries have unique keys compared to standard queries. + * If true a suspense query will have "Suspense" appended to its query key, otherwise it will have the same query key as it's standard query variant. + */ + uniqueSuspenseQueryKeys?: boolean; + /** * @default false * @description If true, it imports `react-query` not `@tanstack/react-query`, default is false. diff --git a/packages/plugins/typescript/react-query/src/fetcher-custom-mapper.ts b/packages/plugins/typescript/react-query/src/fetcher-custom-mapper.ts index 952ce79e5e..53f6693b20 100644 --- a/packages/plugins/typescript/react-query/src/fetcher-custom-mapper.ts +++ b/packages/plugins/typescript/react-query/src/fetcher-custom-mapper.ts @@ -46,7 +46,11 @@ export class CustomMapperFetcher extends FetcherRenderer { return null; } - generateInfiniteQueryHook(config: GenerateConfig, isSuspense = false): string { + generateInfiniteQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { const { documentVariableName, operationResultType, operationVariablesTypes } = config; const typedFetcher = this.getFetcherFnName(operationResultType, operationVariablesTypes); @@ -57,7 +61,11 @@ export class CustomMapperFetcher extends FetcherRenderer { ? `(metaData) => query({...variables, ...(metaData.pageParam ?? {})})` : `(metaData) => ${typedFetcher}(${documentVariableName}, {...variables, ...(metaData.pageParam ?? {})})()`; - const { generateBaseInfiniteQueryHook } = this.generateInfiniteQueryHelper(config, isSuspense); + const { generateBaseInfiniteQueryHook } = this.generateInfiniteQueryHelper( + config, + isSuspense, + uniqueSuspenseQueryKeys, + ); return generateBaseInfiniteQueryHook({ implHookOuter, @@ -65,8 +73,16 @@ export class CustomMapperFetcher extends FetcherRenderer { }); } - generateQueryHook(config: GenerateConfig, isSuspense = false): string { - const { generateBaseQueryHook } = this.generateQueryHelper(config, isSuspense); + generateQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { + const { generateBaseQueryHook } = this.generateQueryHelper( + config, + isSuspense, + uniqueSuspenseQueryKeys, + ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; diff --git a/packages/plugins/typescript/react-query/src/fetcher-fetch-hardcoded.ts b/packages/plugins/typescript/react-query/src/fetcher-fetch-hardcoded.ts index db7ba96354..08c13746cc 100644 --- a/packages/plugins/typescript/react-query/src/fetcher-fetch-hardcoded.ts +++ b/packages/plugins/typescript/react-query/src/fetcher-fetch-hardcoded.ts @@ -59,8 +59,16 @@ ${this.getFetchParams()} }`; } - generateInfiniteQueryHook(config: GenerateConfig, isSuspense = false): string { - const { generateBaseInfiniteQueryHook } = this.generateInfiniteQueryHelper(config, isSuspense); + generateInfiniteQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { + const { generateBaseInfiniteQueryHook } = this.generateInfiniteQueryHelper( + config, + isSuspense, + uniqueSuspenseQueryKeys, + ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; @@ -69,8 +77,16 @@ ${this.getFetchParams()} }); } - generateQueryHook(config: GenerateConfig, isSuspense = false): string { - const { generateBaseQueryHook } = this.generateQueryHelper(config, isSuspense); + generateQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { + const { generateBaseQueryHook } = this.generateQueryHelper( + config, + isSuspense, + uniqueSuspenseQueryKeys, + ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; diff --git a/packages/plugins/typescript/react-query/src/fetcher-fetch.ts b/packages/plugins/typescript/react-query/src/fetcher-fetch.ts index ee93639132..05f6bce4bc 100644 --- a/packages/plugins/typescript/react-query/src/fetcher-fetch.ts +++ b/packages/plugins/typescript/react-query/src/fetcher-fetch.ts @@ -31,10 +31,15 @@ function fetcher(endpoint: string, requestInit: RequestInit, }`; } - generateInfiniteQueryHook(config: GenerateConfig, isSuspense = false): string { + generateInfiniteQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { const { generateBaseInfiniteQueryHook, variables, options } = this.generateInfiniteQueryHelper( config, isSuspense, + uniqueSuspenseQueryKeys, ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; @@ -49,10 +54,15 @@ function fetcher(endpoint: string, requestInit: RequestInit, }); } - generateQueryHook(config: GenerateConfig, isSuspense = false): string { + generateQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { const { generateBaseQueryHook, variables, options } = this.generateQueryHelper( config, isSuspense, + uniqueSuspenseQueryKeys, ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; diff --git a/packages/plugins/typescript/react-query/src/fetcher-graphql-request.ts b/packages/plugins/typescript/react-query/src/fetcher-graphql-request.ts index 1b8b383270..7e0b5f2a93 100644 --- a/packages/plugins/typescript/react-query/src/fetcher-graphql-request.ts +++ b/packages/plugins/typescript/react-query/src/fetcher-graphql-request.ts @@ -35,7 +35,11 @@ function fetcher(client: Graph }`; } - generateInfiniteQueryHook(config: GenerateConfig, isSuspense = false): string { + generateInfiniteQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { const typeImport = this.visitor.config.useTypeImports ? 'import type' : 'import'; if (this.clientPath) this.visitor.imports.add(this.clientPath); this.visitor.imports.add(`${typeImport} { GraphQLClient } from 'graphql-request';`); @@ -43,6 +47,7 @@ function fetcher(client: Graph const { generateBaseInfiniteQueryHook, variables, options } = this.generateInfiniteQueryHelper( config, isSuspense, + uniqueSuspenseQueryKeys, ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; @@ -68,7 +73,11 @@ function fetcher(client: Graph }); } - generateQueryHook(config: GenerateConfig, isSuspense = false): string { + generateQueryHook( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ): string { const typeImport = this.visitor.config.useTypeImports ? 'import type' : 'import'; if (this.clientPath) this.visitor.imports.add(this.clientPath); this.visitor.imports.add(`${typeImport} { GraphQLClient } from 'graphql-request';`); @@ -79,6 +88,7 @@ function fetcher(client: Graph const { generateBaseQueryHook, variables, options } = this.generateQueryHelper( config, isSuspense, + uniqueSuspenseQueryKeys, ); const { documentVariableName, operationResultType, operationVariablesTypes } = config; diff --git a/packages/plugins/typescript/react-query/src/fetcher.ts b/packages/plugins/typescript/react-query/src/fetcher.ts index 9d7991ad7c..e52aef1a77 100644 --- a/packages/plugins/typescript/react-query/src/fetcher.ts +++ b/packages/plugins/typescript/react-query/src/fetcher.ts @@ -32,10 +32,15 @@ export abstract class FetcherRenderer { public abstract generateFetcherImplementation(): string; public abstract generateFetcherFetch(config: GenerateConfig): string; - protected abstract generateQueryHook(config: GenerateConfig, isSuspense?: boolean): string; + protected abstract generateQueryHook( + config: GenerateConfig, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, + ): string; protected abstract generateInfiniteQueryHook( config: GenerateConfig, - isSuspense?: boolean, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, ): string; protected abstract generateMutationHook(config: GenerateConfig): string; @@ -60,7 +65,11 @@ export abstract class FetcherRenderer { return queryMethodMap; } - protected generateInfiniteQueryHelper(config: GenerateConfig, isSuspense: boolean) { + protected generateInfiniteQueryHelper( + config: GenerateConfig, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, + ) { const { operationResultType, operationName } = config; const { infiniteQuery } = this.createQueryMethodMap(isSuspense); @@ -99,7 +108,7 @@ export abstract class FetcherRenderer { ${implHookOuter} return ${infiniteQuery.getHook()}<${operationResultType}, TError, TData>( ${this.generateInfiniteQueryFormattedParameters( - this.generateInfiniteQueryKey(config, isSuspense), + this.generateInfiniteQueryKey(config, isSuspense, uniqueSuspenseQueryKeys), implFetcher, )} )};`; @@ -108,7 +117,11 @@ export abstract class FetcherRenderer { return { generateBaseInfiniteQueryHook, variables, options }; } - protected generateQueryHelper(config: GenerateConfig, isSuspense: boolean) { + protected generateQueryHelper( + config: GenerateConfig, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, + ) { const { operationName, operationResultType } = config; const { query } = this.createQueryMethodMap(isSuspense); @@ -136,7 +149,7 @@ export abstract class FetcherRenderer { ${implHookOuter} return ${query.getHook()}<${operationResultType}, TError, TData>( ${this.generateQueryFormattedParameters( - this.generateQueryKey(config, isSuspense), + this.generateQueryKey(config, isSuspense, uniqueSuspenseQueryKeys), implFetcher, )} )};`; @@ -222,42 +235,63 @@ export abstract class FetcherRenderer { return `options: Omit<${infiniteQuery.getOptions()}<${operationResultType}, TError, TData>, 'queryKey'> & { queryKey?: ${infiniteQuery.getOptions()}<${operationResultType}, TError, TData>['queryKey'] }`; } - public generateInfiniteQueryKey(config: GenerateConfig, isSuspense: boolean): string { - const identifier = isSuspense ? 'infiniteSuspense' : 'infinite'; + public generateInfiniteQueryKey( + config: GenerateConfig, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, + ): string { + const identifier = isSuspense + ? `infinite${uniqueSuspenseQueryKeys ? 'Suspense' : ''}` + : 'infinite'; if (config.hasRequiredVariables) return `['${config.node.name.value}.${identifier}', variables]`; return `variables === undefined ? ['${config.node.name.value}.${identifier}'] : ['${config.node.name.value}.${identifier}', variables]`; } - public generateInfiniteQueryOutput(config: GenerateConfig, isSuspense = false) { + public generateInfiniteQueryOutput( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ) { const { infiniteQuery } = this.createQueryMethodMap(isSuspense); const signature = this.generateQueryVariablesSignature(config); const { operationName, node } = config; return { - hook: this.generateInfiniteQueryHook(config, isSuspense), + hook: this.generateInfiniteQueryHook(config, isSuspense, uniqueSuspenseQueryKeys), getKey: `${infiniteQuery.getHook( operationName, - )}.getKey = (${signature}) => ${this.generateInfiniteQueryKey(config, isSuspense)};`, + )}.getKey = (${signature}) => ${this.generateInfiniteQueryKey(config, isSuspense, uniqueSuspenseQueryKeys)};`, rootKey: `${infiniteQuery.getHook(operationName)}.rootKey = '${node.name.value}.infinite';`, }; } - public generateQueryKey(config: GenerateConfig, isSuspense: boolean): string { - const identifier = isSuspense ? `${config.node.name.value}Suspense` : config.node.name.value; + public generateQueryKey( + config: GenerateConfig, + isSuspense: boolean, + uniqueSuspenseQueryKeys: boolean, + ): string { + const identifier = isSuspense + ? `${config.node.name.value}${uniqueSuspenseQueryKeys ? 'Suspense' : ''}` + : config.node.name.value; if (config.hasRequiredVariables) return `['${identifier}', variables]`; return `variables === undefined ? ['${identifier}'] : ['${identifier}', variables]`; } - public generateQueryOutput(config: GenerateConfig, isSuspense = false) { + public generateQueryOutput( + config: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean, + ) { const { query } = this.createQueryMethodMap(isSuspense); const signature = this.generateQueryVariablesSignature(config); const { operationName, node, documentVariableName } = config; return { - hook: this.generateQueryHook(config, isSuspense), + hook: this.generateQueryHook(config, isSuspense, uniqueSuspenseQueryKeys), document: `${query.getHook(operationName)}.document = ${documentVariableName};`, getKey: `${query.getHook(operationName)}.getKey = (${signature}) => ${this.generateQueryKey( config, isSuspense, + uniqueSuspenseQueryKeys, )};`, rootKey: `${query.getHook(operationName)}.rootKey = '${node.name.value}';`, }; diff --git a/packages/plugins/typescript/react-query/src/visitor.ts b/packages/plugins/typescript/react-query/src/visitor.ts index ec3a5d17c5..eb857f7be4 100644 --- a/packages/plugins/typescript/react-query/src/visitor.ts +++ b/packages/plugins/typescript/react-query/src/visitor.ts @@ -45,6 +45,7 @@ export class ReactQueryVisitor extends ClientSideBaseVisitor< exposeFetcher: getConfigValue(rawConfig.exposeFetcher, false), addInfiniteQuery: getConfigValue(rawConfig.addInfiniteQuery, false), addSuspenseQuery: getConfigValue(rawConfig.addSuspenseQuery, false), + uniqueSuspenseQueryKeys: getConfigValue(rawConfig.uniqueSuspenseQueryKeys, true), reactQueryVersion: getConfigValue(rawConfig.reactQueryVersion, defaultReactQueryVersion), reactQueryImportFrom: getConfigValue(rawConfig.reactQueryImportFrom, ''), }); @@ -149,10 +150,15 @@ export class ReactQueryVisitor extends ClientSideBaseVisitor< const getOutputFromQueries = () => `\n${queries.join('\n\n')}\n`; if (operationType === 'Query') { - const addQuery = (generateConfig: GenerateConfig, isSuspense = false) => { + const addQuery = ( + generateConfig: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean = true, + ) => { const { hook, getKey, rootKey, document } = this.fetcher.generateQueryOutput( generateConfig, isSuspense, + uniqueSuspenseQueryKeys, ); queries.push(hook); if (this.config.exposeDocument) queries.push(document); @@ -162,13 +168,19 @@ export class ReactQueryVisitor extends ClientSideBaseVisitor< addQuery(generateConfig); - if (this.config.addSuspenseQuery) addQuery(generateConfig, true); + if (this.config.addSuspenseQuery) + addQuery(generateConfig, true, this.config.uniqueSuspenseQueryKeys); if (this.config.addInfiniteQuery) { - const addInfiniteQuery = (generateConfig: GenerateConfig, isSuspense = false) => { + const addInfiniteQuery = ( + generateConfig: GenerateConfig, + isSuspense = false, + uniqueSuspenseQueryKeys: boolean = true, + ) => { const { hook, getKey, rootKey } = this.fetcher.generateInfiniteQueryOutput( generateConfig, isSuspense, + uniqueSuspenseQueryKeys, ); queries.push(hook); if (this.config.exposeQueryKeys) queries.push(getKey); @@ -178,7 +190,7 @@ export class ReactQueryVisitor extends ClientSideBaseVisitor< addInfiniteQuery(generateConfig); if (this.config.addSuspenseQuery) { - addInfiniteQuery(generateConfig, true); + addInfiniteQuery(generateConfig, true, this.config.uniqueSuspenseQueryKeys); } } // The reason we're looking at the private field of the CustomMapperFetcher to see if it's a react hook diff --git a/packages/plugins/typescript/react-query/tests/__snapshots__/react-query.spec.ts.snap b/packages/plugins/typescript/react-query/tests/__snapshots__/react-query.spec.ts.snap index 9c7f5655e4..111d20245a 100644 --- a/packages/plugins/typescript/react-query/tests/__snapshots__/react-query.spec.ts.snap +++ b/packages/plugins/typescript/react-query/tests/__snapshots__/react-query.spec.ts.snap @@ -1,5 +1,150 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`React-Query config: uniqueSuspenseQueryKeys reuses the same query keys between suspense and standard queries when set to false: content 1`] = ` +" + +export const TestDocument = \` + query test { + feed { + id + commentCount + repository { + full_name + html_url + owner { + avatar_url + } + } + } +} + \`; + +export const useTestQuery = < + TData = TestQuery, + TError = unknown + >( + dataSource: { endpoint: string, fetchParams?: RequestInit }, + variables?: TestQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: variables === undefined ? ['test'] : ['test', variables], + queryFn: fetcher(dataSource.endpoint, dataSource.fetchParams || {}, TestDocument, variables), + ...options + } + )}; + +export const useSuspenseTestQuery = < + TData = TestQuery, + TError = unknown + >( + dataSource: { endpoint: string, fetchParams?: RequestInit }, + variables?: TestQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseSuspenseQueryOptions['queryKey'] } + ) => { + + return useSuspenseQuery( + { + queryKey: variables === undefined ? ['test'] : ['test', variables], + queryFn: fetcher(dataSource.endpoint, dataSource.fetchParams || {}, TestDocument, variables), + ...options + } + )}; + +export const useInfiniteTestQuery = < + TData = InfiniteData, + TError = unknown + >( + dataSource: { endpoint: string, fetchParams?: RequestInit }, + variables: TestQueryVariables, + options: Omit, 'queryKey'> & { queryKey?: UseInfiniteQueryOptions['queryKey'] } + ) => { + + return useInfiniteQuery( + (() => { + const { queryKey: optionsQueryKey, ...restOptions } = options; + return { + queryKey: optionsQueryKey ?? variables === undefined ? ['test.infinite'] : ['test.infinite', variables], + queryFn: (metaData) => fetcher(dataSource.endpoint, dataSource.fetchParams || {}, TestDocument, {...variables, ...(metaData.pageParam ?? {})})(), + ...restOptions + } + })() + )}; + +export const useSuspenseInfiniteTestQuery = < + TData = InfiniteData, + TError = unknown + >( + dataSource: { endpoint: string, fetchParams?: RequestInit }, + variables: TestQueryVariables, + options: Omit, 'queryKey'> & { queryKey?: UseSuspenseInfiniteQueryOptions['queryKey'] } + ) => { + + return useSuspenseInfiniteQuery( + (() => { + const { queryKey: optionsQueryKey, ...restOptions } = options; + return { + queryKey: optionsQueryKey ?? variables === undefined ? ['test.infinite'] : ['test.infinite', variables], + queryFn: (metaData) => fetcher(dataSource.endpoint, dataSource.fetchParams || {}, TestDocument, {...variables, ...(metaData.pageParam ?? {})})(), + ...restOptions + } + })() + )}; + +export const TestDocument = \` + mutation test($name: String) { + submitRepository(repoFullName: $name) { + id + } +} + \`; + +export const useTestMutation = < + TError = unknown, + TContext = unknown + >( + dataSource: { endpoint: string, fetchParams?: RequestInit }, + options?: UseMutationOptions + ) => { + + return useMutation( + { + mutationKey: ['test'], + mutationFn: (variables?: TestMutationVariables) => fetcher(dataSource.endpoint, dataSource.fetchParams || {}, TestDocument, variables)(), + ...options + } + )}; +" +`; + +exports[`React-Query config: uniqueSuspenseQueryKeys reuses the same query keys between suspense and standard queries when set to false: prepend 1`] = ` +[ + "import { useQuery, useSuspenseQuery, useInfiniteQuery, useSuspenseInfiniteQuery, useMutation, UseQueryOptions, UseSuspenseQueryOptions, UseInfiniteQueryOptions, InfiniteData, UseSuspenseInfiniteQueryOptions, UseMutationOptions } from '@tanstack/react-query';", + " +function fetcher(endpoint: string, requestInit: RequestInit, query: string, variables?: TVariables) { + return async (): Promise => { + const res = await fetch(endpoint, { + method: 'POST', + ...requestInit, + body: JSON.stringify({ query, variables }), + }); + + const json = await res.json(); + + if (json.errors) { + const { message } = json.errors[0]; + + throw new Error(message); + } + + return json.data; + } +}", +] +`; + exports[`React-Query exposeQueryKeys: true Should generate getKey for each query 1`] = ` " diff --git a/packages/plugins/typescript/react-query/tests/react-query.spec.ts b/packages/plugins/typescript/react-query/tests/react-query.spec.ts index 1346c24acd..0f13827a2a 100644 --- a/packages/plugins/typescript/react-query/tests/react-query.spec.ts +++ b/packages/plugins/typescript/react-query/tests/react-query.spec.ts @@ -74,6 +74,23 @@ describe('React-Query', () => { await validateTypeScript(mergeOutputs(out), schema, docs, config); }); + describe('config: uniqueSuspenseQueryKeys', () => { + it('reuses the same query keys between suspense and standard queries when set to false', async () => { + const config: ReactQueryRawPluginConfig = { + reactQueryVersion: 5, + addInfiniteQuery: true, + addSuspenseQuery: true, + uniqueSuspenseQueryKeys: false, + }; + + const out = (await plugin(schema, docs, config)) as Types.ComplexPluginOutput; + + expect(out.prepend).toMatchSnapshot('prepend'); + expect(out.content).toMatchSnapshot('content'); + await validateTypeScript(mergeOutputs(out), schema, docs, config); + }); + }); + it('Duplicated nested fragments are removed', async () => { const schema = buildSchema(/* GraphQL */ ` schema {