diff --git a/.changeset/hungry-eagles-kick.md b/.changeset/hungry-eagles-kick.md new file mode 100644 index 00000000000..b83f7969726 --- /dev/null +++ b/.changeset/hungry-eagles-kick.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': minor +--- + +prevent accidental widening of inferred TData and TVariables generics for query hook option arguments diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index 213f66dc129..4c942593a42 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -915,3 +915,18 @@ describe("useFragment", () => { }); }); }); + +describe.skip("Type Tests", () => { + test('NoInfer prevents adding arbitrary additional variables', () => { + const typedNode = {} as TypedDocumentNode<{ foo: string}, { bar: number }> + useFragment({ + fragment: typedNode, + from: { __typename: "Query" }, + variables: { + bar: 4, + // @ts-expect-error + nonExistingVariable: "string" + } + }); + }) +}) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 9be6b815ec0..018873d4b4c 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -1328,3 +1328,19 @@ describe('useLazyQuery Hook', () => { }); }); }); + +describe.skip("Type Tests", () => { + test('NoInfer prevents adding arbitrary additional variables', () => { + const typedNode = {} as TypedDocumentNode<{ foo: string}, { bar: number }> + const [_, { variables }] = useLazyQuery(typedNode, { + variables: { + bar: 4, + // @ts-expect-error + nonExistingVariable: "string" + } + }); + variables?.bar + // @ts-expect-error + variables?.nonExistingVariable + }) +}) diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 2f02d0c6869..8c74a8f0a46 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -2567,3 +2567,16 @@ describe('useMutation Hook', () => { }); }); }); + +describe.skip("Type Tests", () => { + test('NoInfer prevents adding arbitrary additional variables', () => { + const typedNode = {} as TypedDocumentNode<{ foo: string}, { bar: number }> + useMutation(typedNode, { + variables: { + bar: 4, + // @ts-expect-error + nonExistingVariable: "string" + } + }); + }) +}) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 9bff8e7120c..7dbb85ac8a7 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -7135,3 +7135,19 @@ describe('useQuery Hook', () => { }); }); }); + +describe.skip("Type Tests", () => { + test('NoInfer prevents adding arbitrary additional variables', () => { + const typedNode = {} as TypedDocumentNode<{ foo: string}, { bar: number }> + const { variables } = useQuery(typedNode, { + variables: { + bar: 4, + // @ts-expect-error + nonExistingVariable: "string" + } + }); + variables?.bar + // @ts-expect-error + variables?.nonExistingVariable + }) +}) diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx index 1c208814bef..0c920df3eb7 100644 --- a/src/react/hooks/__tests__/useSubscription.test.tsx +++ b/src/react/hooks/__tests__/useSubscription.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; import gql from 'graphql-tag'; -import { ApolloClient, ApolloError, ApolloLink, concat } from '../../../core'; +import { ApolloClient, ApolloError, ApolloLink, concat, TypedDocumentNode } from '../../../core'; import { InMemoryCache as Cache } from '../../../cache'; import { ApolloProvider, resetApolloContext } from '../../context'; import { MockSubscriptionLink } from '../../../testing'; @@ -933,3 +933,19 @@ describe('useSubscription Hook', () => { warningSpy.mockRestore(); }); }); + +describe.skip("Type Tests", () => { + test('NoInfer prevents adding arbitrary additional variables', () => { + const typedNode = {} as TypedDocumentNode<{ foo: string}, { bar: number }> + const { variables } = useSubscription(typedNode, { + variables: { + bar: 4, + // @ts-expect-error + nonExistingVariable: "string" + } + }); + variables?.bar + // @ts-expect-error + variables?.nonExistingVariable + }) +}) diff --git a/src/react/hooks/useFragment.ts b/src/react/hooks/useFragment.ts index 187a83222aa..9246aaebbf6 100644 --- a/src/react/hooks/useFragment.ts +++ b/src/react/hooks/useFragment.ts @@ -12,10 +12,11 @@ import { import { useApolloClient } from "./useApolloClient"; import { useSyncExternalStore } from "./useSyncExternalStore"; import { OperationVariables } from "../../core"; +import { NoInfer } from "../types/types"; export interface UseFragmentOptions extends Omit< - Cache.DiffOptions, + Cache.DiffOptions, NoInfer>, | "id" | "query" | "optimistic" @@ -23,6 +24,7 @@ extends Omit< >, Omit< Cache.ReadFragmentOptions, | "id" + | "variables" > { from: StoreObject | Reference | string; // Override this field to make it optional (default: true). diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index e0ffeab91dc..42febe96bac 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -7,6 +7,7 @@ import { mergeOptions } from '../../utilities'; import { LazyQueryHookOptions, LazyQueryResultTuple, + NoInfer, QueryResult, } from '../types/types'; import { useInternalState } from './useQuery'; @@ -25,7 +26,7 @@ const EAGER_METHODS = [ export function useLazyQuery( query: DocumentNode | TypedDocumentNode, - options?: LazyQueryHookOptions + options?: LazyQueryHookOptions, NoInfer> ): LazyQueryResultTuple { const abortControllersRef = useRef(new Set()); diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 4937c08ba6f..382fef3c1a8 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -6,6 +6,7 @@ import { MutationHookOptions, MutationResult, MutationTuple, + NoInfer, } from '../types/types'; import { @@ -26,7 +27,7 @@ export function useMutation< TCache extends ApolloCache = ApolloCache, >( mutation: DocumentNode | TypedDocumentNode, - options?: MutationHookOptions, + options?: MutationHookOptions, NoInfer, TContext, TCache>, ): MutationTuple { const client = useApolloClient(options?.client); verifyDocumentType(mutation, DocumentType.Mutation); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 97e010ad4ff..953fe9ca497 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -26,6 +26,7 @@ import { QueryHookOptions, QueryResult, ObservableQueryFields, + NoInfer, } from '../types/types'; import { DocumentType, verifyDocumentType } from '../parser'; @@ -43,7 +44,7 @@ export function useQuery< TVariables extends OperationVariables = OperationVariables, >( query: DocumentNode | TypedDocumentNode, - options: QueryHookOptions = Object.create(null), + options: QueryHookOptions, NoInfer> = Object.create(null), ): QueryResult { return useInternalState( useApolloClient(options.client), diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index 03905531ab5..fb53c543af9 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -6,6 +6,7 @@ import { equal } from '@wry/equality'; import { DocumentType, verifyDocumentType } from '../parser'; import { + NoInfer, SubscriptionHookOptions, SubscriptionResult } from '../types/types'; @@ -14,12 +15,12 @@ import { useApolloClient } from './useApolloClient'; export function useSubscription( subscription: DocumentNode | TypedDocumentNode, - options?: SubscriptionHookOptions, + options?: SubscriptionHookOptions, NoInfer>, ) { const hasIssuedDeprecationWarningRef = useRef(false); const client = useApolloClient(options?.client); verifyDocumentType(subscription, DocumentType.Subscription); - const [result, setResult] = useState>({ + const [result, setResult] = useState>({ loading: !options?.skip, error: void 0, data: void 0, diff --git a/src/react/types/types.ts b/src/react/types/types.ts index cdff55c382d..5eb41637c81 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -307,3 +307,30 @@ export interface SubscriptionCurrentObservable { query?: Observable; subscription?: ObservableSubscription; } + +/** +Helper type that allows using a type in a way that cannot be "widened" by inference on the value it is used on. + +This type was first suggested [in this Github discussion](https://github.com/microsoft/TypeScript/issues/14829#issuecomment-504042546). + +Example usage: +```ts +export function useQuery< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: QueryHookOptions, NoInfer> = Object.create(null), +) +``` +In this case, `TData` and `TVariables` should be inferred from `query`, but never widened from something in `options`. + +So, in this code example: +```ts +declare const typedNode: TypedDocumentNode<{ foo: string}, { bar: number }> +const { variables } = useQuery(typedNode, { variables: { bar: 4, nonExistingVariable: "string" } }); +``` +Without the use of `NoInfer`, `variables` would now be of the type `{ bar: number, nonExistingVariable: "string" }`. +With `NoInfer`, it will instead give an error on `nonExistingVariable`. + */ +export type NoInfer = [T][T extends any ? 0 : never]