diff --git a/.api-reports/api-report-cache.md b/.api-reports/api-report-cache.md index c84e7527da6..2efb3c2ed67 100644 --- a/.api-reports/api-report-cache.md +++ b/.api-reports/api-report-cache.md @@ -355,6 +355,12 @@ export abstract class EntityStore implements NormalizedCache { // (undocumented) protected lookup(dataId: string, dependOnExistence?: boolean): StoreObject | undefined; // (undocumented) + makeCacheKey(document: DocumentNode, callback: Cache_2.WatchCallback, details: string): object; + // (undocumented) + makeCacheKey(selectionSet: SelectionSetNode, parent: string | StoreObject, varString: string | undefined, canonizeResults: boolean): object; + // (undocumented) + makeCacheKey(field: FieldNode, array: readonly any[], varString: string | undefined): object; + // (undocumented) makeCacheKey(...args: any[]): object; // (undocumented) merge(older: string | StoreObject, newer: StoreObject | string): void; diff --git a/.api-reports/api-report-core.md b/.api-reports/api-report-core.md index f8c570ceebc..4a8dfc429e8 100644 --- a/.api-reports/api-report-core.md +++ b/.api-reports/api-report-core.md @@ -670,6 +670,12 @@ abstract class EntityStore implements NormalizedCache { // (undocumented) protected lookup(dataId: string, dependOnExistence?: boolean): StoreObject | undefined; // (undocumented) + makeCacheKey(document: DocumentNode, callback: Cache_2.WatchCallback, details: string): object; + // (undocumented) + makeCacheKey(selectionSet: SelectionSetNode, parent: string | StoreObject, varString: string | undefined, canonizeResults: boolean): object; + // (undocumented) + makeCacheKey(field: FieldNode, array: readonly any[], varString: string | undefined): object; + // (undocumented) makeCacheKey(...args: any[]): object; // (undocumented) merge(older: string | StoreObject, newer: StoreObject | string): void; @@ -2195,9 +2201,9 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:126:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:113:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:114:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:119:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:153:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:384:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:191:3 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react.md b/.api-reports/api-report-react.md index 968c7ec60c4..65a4bbe7d1f 100644 --- a/.api-reports/api-report-react.md +++ b/.api-reports/api-report-react.md @@ -1018,6 +1018,38 @@ export type LazyQueryResultTuple = // @public (undocumented) type Listener = (promise: Promise>) => void; +// @public (undocumented) +export type LoadableQueryHookFetchPolicy = Extract; + +// @public (undocumented) +export interface LoadableQueryHookOptions { + // (undocumented) + canonizeResults?: boolean; + // (undocumented) + client?: ApolloClient; + // (undocumented) + context?: Context; + // Warning: (ae-forgotten-export) The symbol "ErrorPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + errorPolicy?: ErrorPolicy; + // (undocumented) + fetchPolicy?: LoadableQueryHookFetchPolicy; + // (undocumented) + queryKey?: string | number | any[]; + // Warning: (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + refetchWritePolicy?: RefetchWritePolicy; + // (undocumented) + returnPartialData?: boolean; +} + +// Warning: (ae-forgotten-export) The symbol "OnlyRequiredProperties" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type LoadQueryFunction = (...args: [TVariables] extends [never] ? [] : {} extends OnlyRequiredProperties ? [variables?: TVariables] : [variables: TVariables]) => void; + // @public (undocumented) class LocalState { // Warning: (ae-forgotten-export) The symbol "LocalStateOptions" needs to be exported by the entry point index.d.ts @@ -1119,8 +1151,6 @@ interface MutationBaseOptions { data: SubscriptionResult; } +// @public (undocumented) +type OnlyRequiredProperties = { + [K in keyof T as {} extends Pick ? never : K]: T[K]; +}; + // @public (undocumented) type OnQueryUpdated = (observableQuery: ObservableQuery, diff: Cache_2.DiffResult, lastDiff: Cache_2.DiffResult | undefined) => boolean | TResult; @@ -1806,6 +1841,9 @@ type RequestHandler = (operation: Operation, forward: NextLink) => Observable void; + // @public (undocumented) type Resolver = (rootValue?: any, args?: any, context?: any, info?: { field: FieldNode; @@ -2098,6 +2136,39 @@ export type UseFragmentResult = { // @public (undocumented) export function useLazyQuery(query: DocumentNode | TypedDocumentNode, options?: LazyQueryHookOptions, NoInfer>): LazyQueryResultTuple; +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions & TOptions): UseLoadableQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? DeepPartial : TData, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult | undefined, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; +}): UseLoadableQueryResult, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions): UseLoadableQueryResult; + +// @public (undocumented) +export type UseLoadableQueryResult = [ +LoadQueryFunction, +QueryReference | null, + { + fetchMore: FetchMoreFunction; + refetch: RefetchFunction; + reset: ResetFunction; +} +]; + // @public (undocumented) export function useMutation = ApolloCache>(mutation: DocumentNode | TypedDocumentNode, options?: MutationHookOptions, NoInfer, TContext, TCache>): MutationTuple; @@ -2199,8 +2270,6 @@ interface WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); - // Warning: (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) refetchWritePolicy?: RefetchWritePolicy; } @@ -2217,9 +2286,9 @@ interface WatchQueryOptions = [LazyQ // @public (undocumented) type Listener = (promise: Promise>) => void; +// @public (undocumented) +type LoadableQueryHookFetchPolicy = Extract; + +// @public (undocumented) +interface LoadableQueryHookOptions { + // (undocumented) + canonizeResults?: boolean; + // (undocumented) + client?: ApolloClient; + // (undocumented) + context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "ErrorPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + errorPolicy?: ErrorPolicy; + // Warning: (ae-forgotten-export) The symbol "LoadableQueryHookFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: LoadableQueryHookFetchPolicy; + // (undocumented) + queryKey?: string | number | any[]; + // Warning: (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + refetchWritePolicy?: RefetchWritePolicy; + // (undocumented) + returnPartialData?: boolean; +} + +// Warning: (ae-forgotten-export) The symbol "OnlyRequiredProperties" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type LoadQueryFunction = (...args: [TVariables] extends [never] ? [] : {} extends OnlyRequiredProperties ? [variables?: TVariables] : [variables: TVariables]) => void; + // @public (undocumented) class LocalState { // Warning: (ae-forgotten-export) The symbol "LocalStateOptions" needs to be exported by the entry point index.d.ts @@ -1070,8 +1104,6 @@ interface MutationBaseOptions { data: SubscriptionResult; } +// @public (undocumented) +type OnlyRequiredProperties = { + [K in keyof T as {} extends Pick ? never : K]: T[K]; +}; + // @public (undocumented) type OnQueryUpdated = (observableQuery: ObservableQuery, diff: Cache_2.DiffResult, lastDiff: Cache_2.DiffResult | undefined) => boolean | TResult; @@ -1696,6 +1733,9 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RequestHandler = (operation: Operation, forward: NextLink) => Observable | null; +// @public (undocumented) +type ResetFunction = () => void; + // @public (undocumented) type Resolver = (rootValue?: any, args?: any, context?: any, info?: { field: FieldNode; @@ -1979,6 +2019,41 @@ export type UseFragmentResult = { // @public (undocumented) export function useLazyQuery(query: DocumentNode | TypedDocumentNode, options?: LazyQueryHookOptions, NoInfer>): LazyQueryResultTuple; +// Warning: (ae-forgotten-export) The symbol "LoadableQueryHookOptions" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions & TOptions): UseLoadableQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? DeepPartial : TData, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult | undefined, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; +}): UseLoadableQueryResult, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions): UseLoadableQueryResult; + +// @public (undocumented) +export type UseLoadableQueryResult = [ +LoadQueryFunction, +QueryReference | null, + { + fetchMore: FetchMoreFunction; + refetch: RefetchFunction; + reset: ResetFunction; +} +]; + // Warning: (ae-forgotten-export) The symbol "MutationHookOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "MutationTuple" needs to be exported by the entry point index.d.ts // @@ -2087,8 +2162,6 @@ interface WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); - // Warning: (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) refetchWritePolicy?: RefetchWritePolicy; } @@ -2105,9 +2178,9 @@ interface WatchQueryOptions(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:113:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:114:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:119:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:153:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:384:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:158:3 - (ae-forgotten-export) The symbol "ApolloError" needs to be exported by the entry point index.d.ts // src/core/types.ts:160:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:178:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing_core.md b/.api-reports/api-report-testing_core.md index 5aaef2383c6..313f20a2f54 100644 --- a/.api-reports/api-report-testing_core.md +++ b/.api-reports/api-report-testing_core.md @@ -1646,9 +1646,9 @@ export function withWarningSpy(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:113:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:114:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:119:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:153:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:384:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:158:3 - (ae-forgotten-export) The symbol "ApolloError" needs to be exported by the entry point index.d.ts // src/core/types.ts:160:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:178:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-utilities.md b/.api-reports/api-report-utilities.md index 93fab5008fc..3ca8456512a 100644 --- a/.api-reports/api-report-utilities.md +++ b/.api-reports/api-report-utilities.md @@ -827,6 +827,12 @@ abstract class EntityStore implements NormalizedCache { // (undocumented) protected lookup(dataId: string, dependOnExistence?: boolean): StoreObject | undefined; // (undocumented) + makeCacheKey(document: DocumentNode, callback: Cache_2.WatchCallback, details: string): object; + // (undocumented) + makeCacheKey(selectionSet: SelectionSetNode, parent: string | StoreObject, varString: string | undefined, canonizeResults: boolean): object; + // (undocumented) + makeCacheKey(field: FieldNode, array: readonly any[], varString: string | undefined): object; + // (undocumented) makeCacheKey(...args: any[]): object; // (undocumented) merge(older: string | StoreObject, newer: StoreObject | string): void; @@ -1797,6 +1803,11 @@ export function offsetLimitPagination(keyArgs?: KeyArgs): FieldPo // @public (undocumented) export function omitDeep(value: T, key: K): DeepOmit; +// @public (undocumented) +export type OnlyRequiredProperties = { + [K in keyof T as {} extends Pick ? never : K]: T[K]; +}; + // @public (undocumented) type OnQueryUpdated = (observableQuery: ObservableQuery, diff: Cache_2.DiffResult, lastDiff: Cache_2.DiffResult | undefined) => boolean | TResult; @@ -2528,9 +2539,9 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/LocalState.ts:71:3 - (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:113:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:114:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:119:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:153:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:384:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:158:3 - (ae-forgotten-export) The symbol "ApolloError" needs to be exported by the entry point index.d.ts // src/core/types.ts:160:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:178:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.md b/.api-reports/api-report.md index e3107b741e2..9e336a0c50a 100644 --- a/.api-reports/api-report.md +++ b/.api-reports/api-report.md @@ -831,6 +831,12 @@ abstract class EntityStore implements NormalizedCache { // (undocumented) protected lookup(dataId: string, dependOnExistence?: boolean): StoreObject | undefined; // (undocumented) + makeCacheKey(document: DocumentNode, callback: Cache_2.WatchCallback, details: string): object; + // (undocumented) + makeCacheKey(selectionSet: SelectionSetNode, parent: string | StoreObject, varString: string | undefined, canonizeResults: boolean): object; + // (undocumented) + makeCacheKey(field: FieldNode, array: readonly any[], varString: string | undefined): object; + // (undocumented) makeCacheKey(...args: any[]): object; // (undocumented) merge(older: string | StoreObject, newer: StoreObject | string): void; @@ -1418,6 +1424,34 @@ export type LazyQueryResultTuple = // @public (undocumented) type Listener = (promise: Promise>) => void; +// @public (undocumented) +export type LoadableQueryHookFetchPolicy = Extract; + +// @public (undocumented) +export interface LoadableQueryHookOptions { + // (undocumented) + canonizeResults?: boolean; + // (undocumented) + client?: ApolloClient; + // (undocumented) + context?: DefaultContext; + // (undocumented) + errorPolicy?: ErrorPolicy; + // (undocumented) + fetchPolicy?: LoadableQueryHookFetchPolicy; + // (undocumented) + queryKey?: string | number | any[]; + // (undocumented) + refetchWritePolicy?: RefetchWritePolicy; + // (undocumented) + returnPartialData?: boolean; +} + +// Warning: (ae-forgotten-export) The symbol "OnlyRequiredProperties" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type LoadQueryFunction = (...args: [TVariables] extends [never] ? [] : {} extends OnlyRequiredProperties ? [variables?: TVariables] : [variables: TVariables]) => void; + // @public (undocumented) class LocalState { // Warning: (ae-forgotten-export) The symbol "LocalStateOptions" needs to be exported by the entry point index.d.ts @@ -1843,6 +1877,11 @@ export interface OnDataOptions { data: SubscriptionResult; } +// @public (undocumented) +type OnlyRequiredProperties = { + [K in keyof T as {} extends Pick ? never : K]: T[K]; +}; + // @public (undocumented) export type OnQueryUpdated = (observableQuery: ObservableQuery, diff: Cache_2.DiffResult, lastDiff: Cache_2.DiffResult | undefined) => boolean | TResult; @@ -2372,6 +2411,9 @@ export const resetApolloContext: typeof getApolloContext; export { resetCaches } +// @public (undocumented) +type ResetFunction = () => void; + // @public (undocumented) export type Resolver = (rootValue?: any, args?: any, context?: any, info?: { field: FieldNode; @@ -2740,6 +2782,39 @@ export type UseFragmentResult = { // @public (undocumented) export function useLazyQuery(query: DocumentNode | TypedDocumentNode, options?: LazyQueryHookOptions, NoInfer>): LazyQueryResultTuple; +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions & TOptions): UseLoadableQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? DeepPartial : TData, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult | undefined, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + errorPolicy: "ignore" | "all"; +}): UseLoadableQueryResult; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options: LoadableQueryHookOptions & { + returnPartialData: true; +}): UseLoadableQueryResult, TVariables>; + +// @public (undocumented) +export function useLoadableQuery(query: DocumentNode | TypedDocumentNode, options?: LoadableQueryHookOptions): UseLoadableQueryResult; + +// @public (undocumented) +export type UseLoadableQueryResult = [ +LoadQueryFunction, +QueryReference | null, + { + fetchMore: FetchMoreFunction; + refetch: RefetchFunction; + reset: ResetFunction; +} +]; + // @public (undocumented) export function useMutation = ApolloCache>(mutation: DocumentNode | TypedDocumentNode, options?: MutationHookOptions, NoInfer, TContext, TCache>): MutationTuple; @@ -2883,13 +2958,14 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:126:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:113:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:114:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:119:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:153:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:384:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:191:3 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts // src/react/hooks/useBackgroundQuery.ts:26:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts // src/react/hooks/useBackgroundQuery.ts:27:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useLoadableQuery.ts:51:5 - (ae-forgotten-export) The symbol "ResetFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/clean-items-smash.md b/.changeset/clean-items-smash.md new file mode 100644 index 00000000000..c0111542c78 --- /dev/null +++ b/.changeset/clean-items-smash.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix a potential memory leak in `FragmentRegistry.transform` and `FragmentRegistry.findFragmentSpreads` that would hold on to passed-in `DocumentNodes` for too long. diff --git a/.changeset/quick-hats-marry.md b/.changeset/quick-hats-marry.md new file mode 100644 index 00000000000..2667f0a9750 --- /dev/null +++ b/.changeset/quick-hats-marry.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +ensure `defaultContext` is also used for mutations and subscriptions diff --git a/.changeset/thick-mice-collect.md b/.changeset/thick-mice-collect.md new file mode 100644 index 00000000000..47ed2e58cfd --- /dev/null +++ b/.changeset/thick-mice-collect.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Clarify types of `EntityStore.makeCacheKey`. diff --git a/.changeset/thirty-ties-arrive.md b/.changeset/thirty-ties-arrive.md new file mode 100644 index 00000000000..c8a6fc22c86 --- /dev/null +++ b/.changeset/thirty-ties-arrive.md @@ -0,0 +1,26 @@ +--- +"@apollo/client": minor +--- + +Introduces a new `useLoadableQuery` hook. This hook works similarly to `useBackgroundQuery` in that it returns a `queryRef` that can be used to suspend a component via the `useReadQuery` hook. It provides a more ergonomic way to load the query during a user interaction (for example when wanting to preload some data) that would otherwise be clunky with `useBackgroundQuery`. + +```tsx +function App() { + const [loadQuery, queryRef, { refetch, fetchMore, reset }] = useLoadableQuery(query, options) + + return ( + <> + + }> + {queryRef && } + + + ); +} + +function Child({ queryRef }) { + const { data } = useReadQuery(queryRef) + + // ... +} +``` diff --git a/.circleci/config.yml b/.circleci/config.yml index 278ee3f4b1d..12aa71cf035 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,7 +15,7 @@ jobs: Lint: docker: - - image: cimg/node:21.1.0 + - image: cimg/node:21.2.0 steps: - checkout - run: npm version @@ -24,7 +24,7 @@ jobs: Formatting: docker: - - image: cimg/node:21.1.0 + - image: cimg/node:21.2.0 steps: - checkout - run: npm ci @@ -32,7 +32,7 @@ jobs: Tests: docker: - - image: cimg/node:21.1.0 + - image: cimg/node:21.2.0 steps: - checkout - run: npm run ci:precheck @@ -50,7 +50,7 @@ jobs: BuildTarball: docker: - - image: cimg/node:21.1.0 + - image: cimg/node:21.2.0 steps: - checkout - run: npm run ci:precheck @@ -67,7 +67,7 @@ jobs: framework: type: string docker: - - image: cimg/node:21.1.0 + - image: cimg/node:21.2.0 steps: - checkout - attach_workspace: diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 8c050ae5b5e..5cc733c8cd7 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -16,7 +16,7 @@ jobs: if: github.repository == 'apollographql/apollo-client' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} log-output: true diff --git a/.size-limit.cjs b/.size-limit.cjs index 674fba0553f..6faa1c00aca 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,6 +20,7 @@ const checks = [ "useSubscription", "useSuspenseQuery", "useBackgroundQuery", + "useLoadableQuery", "useReadQuery", "useFragment", ].map((name) => ({ path: "dist/react/index.js", import: `{ ${name} }` })), diff --git a/.size-limits.json b/.size-limits.json index 633c6a52fd8..d5dd8296590 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 38201, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32206 + "dist/apollo-client.min.cjs": 38603, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32203 } diff --git a/ROADMAP.md b/ROADMAP.md index 59a9e819500..9a9bf125c56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # 🔮 Apollo Client Roadmap -**Last updated: 2023-11-07** +**Last updated: 2023-11-21** For up to date release notes, refer to the project's [Changelog](https://github.com/apollographql/apollo-client/blob/main/CHANGELOG.md). @@ -22,8 +22,8 @@ Tentative beta date: Dec 1, 2023 Features we plan to tackle: - Ability to preload a query outside of a React component that can be used with `useReadQuery` to suspend while loading -- Introduce a new `useInteractiveQuery`/`useLazyBackgroundQuery` hook (name TBD) -- Improved testing utilities +- Introduce a new `useLoadableQuery` hook +- `` improvements - Optimizing memory usage in SSR scenarios > NOTE: These are subject to change and are not guaranteed to be part of 3.9 at the time of this writing. diff --git a/config/jest.config.js b/config/jest.config.js index 3dcd6e6de56..a45df96fc48 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -31,10 +31,11 @@ const ignoreTSXFiles = ".tsx$"; const react17TestFileIgnoreList = [ ignoreTSFiles, - // For now, we only support useSuspenseQuery with React 18, so no need to test - // it with React 17 + // We only support Suspense with React 18, so don't test suspense hooks with + // React 17 "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", + "src/react/hooks/__tests__/useLoadableQuery.test.tsx", ]; const tsStandardConfig = { diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index 29300dd1db3..3a6eec83618 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -211,6 +211,8 @@ const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, { }); ``` +> You can only refetch _active_ queries. Active queries are those used by components on the current page. If the data you want to update is not fetched by a component on the current page, it's best to [update your cache directly](#updating-the-cache-directly). + Each element in the `refetchQueries` array is one of the following: * A `DocumentNode` object parsed with the `gql` function diff --git a/docs/source/data/queries.mdx b/docs/source/data/queries.mdx index b0fe6140095..2f095ad69c6 100644 --- a/docs/source/data/queries.mdx +++ b/docs/source/data/queries.mdx @@ -155,7 +155,7 @@ function DogPhoto({ breed }) { return (
-
@@ -211,7 +211,7 @@ function DogPhoto({ breed }) { return (
-
diff --git a/package-lock.json b/package-lock.json index 2217dbde199..e879e9a11bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@wry/caches": "^1.0.0", "@wry/context": "^0.7.3", "@wry/equality": "^0.5.6", - "@wry/trie": "^0.4.3", + "@wry/trie": "^0.5.0", "graphql-tag": "^2.12.6", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.17.5", @@ -3329,9 +3329,9 @@ } }, "node_modules/@wry/trie": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", - "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", "dependencies": { "tslib": "^2.3.0" }, @@ -8953,6 +8953,17 @@ "tslib": "^2.3.0" } }, + "node_modules/optimism/node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", diff --git a/package.json b/package.json index 0cfdabf4dbd..64adc0f0f3c 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@wry/caches": "^1.0.0", "@wry/context": "^0.7.3", "@wry/equality": "^0.5.6", - "@wry/trie": "^0.4.3", + "@wry/trie": "^0.5.0", "graphql-tag": "^2.12.6", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.17.5", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 1d9a73e0eb7..70229c88a17 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -59,6 +59,7 @@ Array [ "useBackgroundQuery", "useFragment", "useLazyQuery", + "useLoadableQuery", "useMutation", "useQuery", "useReactiveVar", @@ -273,6 +274,7 @@ Array [ "useBackgroundQuery", "useFragment", "useLazyQuery", + "useLoadableQuery", "useMutation", "useQuery", "useReactiveVar", @@ -316,6 +318,7 @@ Array [ "useBackgroundQuery", "useFragment", "useLazyQuery", + "useLoadableQuery", "useMutation", "useQuery", "useReactiveVar", diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index a31f96db63a..4520d9740da 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -32,6 +32,7 @@ import type { DeleteModifier, ModifierDetails, } from "../core/types/common.js"; +import type { DocumentNode, FieldNode, SelectionSetNode } from "graphql"; const DELETE: DeleteModifier = Object.create(null); const delModifier: Modifier = () => DELETE; @@ -522,6 +523,27 @@ export abstract class EntityStore implements NormalizedCache { } // Used to compute cache keys specific to this.group. + /** overload for `InMemoryCache.maybeBroadcastWatch` */ + public makeCacheKey( + document: DocumentNode, + callback: Cache.WatchCallback, + details: string + ): object; + /** overload for `StoreReader.executeSelectionSet` */ + public makeCacheKey( + selectionSet: SelectionSetNode, + parent: string /* = ( Reference.__ref ) */ | StoreObject, + varString: string | undefined, + canonizeResults: boolean + ): object; + /** overload for `StoreReader.executeSubSelectedArray` */ + public makeCacheKey( + field: FieldNode, + array: readonly any[], + varString: string | undefined + ): object; + /** @deprecated This is only meant for internal usage, + * in your own code please use a `Trie` instance instead. */ public makeCacheKey(...args: any[]): object; public makeCacheKey() { return this.group.keyMaker.lookupArray(arguments); diff --git a/src/cache/inmemory/fragmentRegistry.ts b/src/cache/inmemory/fragmentRegistry.ts index f7db169e3b0..12cade01aea 100644 --- a/src/cache/inmemory/fragmentRegistry.ts +++ b/src/cache/inmemory/fragmentRegistry.ts @@ -6,7 +6,6 @@ import type { } from "graphql"; import { visit } from "graphql"; -import type { OptimisticWrapperFunction } from "optimism"; import { wrap } from "optimism"; import type { FragmentMap } from "../../utilities/index.js"; @@ -66,15 +65,12 @@ class FragmentRegistry implements FragmentRegistryAPI { private invalidate(name: string) {} public resetCaches() { - this.invalidate = (this.lookup = this.cacheUnaryMethod(this.lookup)).dirty; // This dirty function is bound to the wrapped lookup method. - this.transform = this.cacheUnaryMethod(this.transform); - this.findFragmentSpreads = this.cacheUnaryMethod(this.findFragmentSpreads); - } - - private cacheUnaryMethod any>(originalMethod: F) { - return wrap, ReturnType>(originalMethod.bind(this), { + const proto = FragmentRegistry.prototype; + this.invalidate = (this.lookup = wrap(proto.lookup.bind(this), { makeCacheKey: (arg) => arg, - }) as OptimisticWrapperFunction, ReturnType> & F; + })).dirty; // This dirty function is bound to the wrapped lookup method. + this.transform = wrap(proto.transform.bind(this)); + this.findFragmentSpreads = wrap(proto.findFragmentSpreads.bind(this)); } public lookup(fragmentName: string): FragmentDefinitionNode | null { diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 4ddbea78b43..35b9b1dce17 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -153,6 +153,10 @@ export class StoreReader { this.canon = config.canon || new ObjectCanon(); + // memoized functions in this class will be "garbage-collected" + // by recreating the whole `StoreReader` in + // `InMemoryCache.resetResultsCache` + // (triggered from `InMemoryCache.gc` with `resetResultCache: true`) this.executeSelectionSet = wrap( (options) => { const { canonizeResults } = options.context; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index e19cfdd71a3..98ab4d1d1f4 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -8,7 +8,6 @@ import { equal } from "@wry/equality"; import type { ApolloLink, FetchResult } from "../link/core/index.js"; import { execute } from "../link/core/index.js"; import { - compact, hasDirectives, isExecutionPatchIncrementalResult, isExecutionPatchResult, @@ -1161,9 +1160,7 @@ export class QueryManager { return asyncMap( this.getObservableFromLink( linkDocument, - // explicitly a shallow merge so any class instances etc. a user might - // put in here will not be merged into each other. - compact(this.defaultContext, options.context), + options.context, options.variables ), @@ -1676,6 +1673,7 @@ export class QueryManager { private prepareContext(context = {}) { const newContext = this.localState.prepareContext(context); return { + ...this.defaultContext, ...newContext, clientAwareness: this.clientAwareness, }; diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index be28a6b575e..6358d171f4c 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -6180,32 +6180,40 @@ describe("QueryManager", () => { }).toThrowError(/Cannot set property defaultContext/); }); - it("`defaultContext` will be applied to the context of a query", async () => { - let context: any; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: new ApolloLink( - (operation) => - new Observable((observer) => { - ({ cache: _, ...context } = operation.getContext()); - observer.complete(); - }) - ), - defaultContext: { - foo: "bar", - }, - }); + it.each([ + ["query", { method: "query", option: "query" }], + ["mutation", { method: "mutate", option: "mutation" }], + ["subscription", { method: "subscribe", option: "query" }], + ] as const)( + "`defaultContext` will be applied to the context of a %s", + async (_, { method, option }) => { + let context: any; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink( + (operation) => + new Observable((observer) => { + ({ cache: _, ...context } = operation.getContext()); + observer.complete(); + }) + ), + defaultContext: { + foo: "bar", + }, + }); - await client.query({ - query: gql` - query { - foo - } - `, - }); + // @ts-ignore a bit too generic for TS + client[method]({ + [option]: gql` + query { + foo + } + `, + }); - expect(context.foo).toBe("bar"); - }); + expect(context.foo).toBe("bar"); + } + ); it("`ApolloClient.defaultContext` can be modified and changes will show up in future queries", async () => { let context: any; diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx index 3aee25d2685..acdd2015301 100644 --- a/src/react/components/__tests__/client/Query.test.tsx +++ b/src/react/components/__tests__/client/Query.test.tsx @@ -1491,7 +1491,7 @@ describe("Query component", () => { return ( {(r: any) => { - ProfiledContainer.updateSnapshot(r); + ProfiledContainer.replaceSnapshot(r); return null; }} diff --git a/src/react/hoc/__tests__/queries/lifecycle.test.tsx b/src/react/hoc/__tests__/queries/lifecycle.test.tsx index 99e7fbc6c5e..cf460af964a 100644 --- a/src/react/hoc/__tests__/queries/lifecycle.test.tsx +++ b/src/react/hoc/__tests__/queries/lifecycle.test.tsx @@ -52,7 +52,7 @@ describe("[queries] lifecycle", () => { })( class extends React.Component> { render() { - ProfiledApp.updateSnapshot(this.props.data!); + ProfiledApp.replaceSnapshot(this.props.data!); return null; } } diff --git a/src/react/hoc/__tests__/queries/loading.test.tsx b/src/react/hoc/__tests__/queries/loading.test.tsx index c2a40c0b9ec..387a6803fb5 100644 --- a/src/react/hoc/__tests__/queries/loading.test.tsx +++ b/src/react/hoc/__tests__/queries/loading.test.tsx @@ -407,7 +407,7 @@ describe("[queries] loading", () => { })( class extends React.Component> { render() { - ProfiledContainer.updateSnapshot(this.props.data!); + ProfiledContainer.replaceSnapshot(this.props.data!); return null; } } diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index 08121af7f27..131364939cd 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -335,7 +335,7 @@ function renderVariablesIntegrationTest({ const ProfiledApp = profile>({ Component: App, snapshotDOM: true, - onRender: ({ updateSnapshot }) => updateSnapshot(cloneDeep(renders)), + onRender: ({ replaceSnapshot }) => replaceSnapshot(cloneDeep(renders)), }); const { ...rest } = render( @@ -434,9 +434,8 @@ function renderPaginatedIntegrationTest({ } function SuspenseFallback() { - ProfiledApp.updateSnapshot((snapshot) => ({ - ...snapshot, - suspenseCount: snapshot.suspenseCount + 1, + ProfiledApp.mergeSnapshot(({ suspenseCount }) => ({ + suspenseCount: suspenseCount + 1, })); return
loading
; } @@ -450,9 +449,8 @@ function renderPaginatedIntegrationTest({ }) { const { data, error } = useReadQuery(queryRef); // count renders in the child component - ProfiledApp.updateSnapshot((snapshot) => ({ - ...snapshot, - count: snapshot.count + 1, + ProfiledApp.mergeSnapshot(({ count }) => ({ + count: count + 1, })); return (
@@ -504,10 +502,9 @@ function renderPaginatedIntegrationTest({ Error
} onError={(error) => { - ProfiledApp.updateSnapshot((snapshot) => ({ - ...snapshot, - errorCount: snapshot.errorCount + 1, - errors: snapshot.errors.concat(error), + ProfiledApp.mergeSnapshot(({ errorCount, errors }) => ({ + errorCount: errorCount + 1, + errors: errors.concat(error), })); }} > diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index 6ef04ad4d01..21b9e083a03 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -1476,7 +1476,7 @@ describe("has the same timing as `useQuery`", () => { from: initialItem, }); - ProfiledComponent.updateSnapshot({ queryData, fragmentData }); + ProfiledComponent.replaceSnapshot({ queryData, fragmentData }); return complete ? JSON.stringify(fragmentData) : "loading"; } diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index a36c0725bea..150ce125fca 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -1115,7 +1115,7 @@ describe("useLazyQuery Hook", () => { ), }); - const [execute] = ProfiledHook.getCurrentSnapshot(); + const [execute] = await ProfiledHook.peekSnapshot(); { const [, result] = await ProfiledHook.takeSnapshot(); diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx new file mode 100644 index 00000000000..e82dc181f66 --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -0,0 +1,4719 @@ +import React, { Suspense, useState } from "react"; +import { + act, + render, + screen, + renderHook, + waitFor, + RenderOptions, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; +import { expectTypeOf } from "expect-type"; +import { GraphQLError } from "graphql"; +import { + gql, + ApolloError, + ApolloClient, + ErrorPolicy, + NetworkStatus, + TypedDocumentNode, + ApolloLink, + Observable, + OperationVariables, + RefetchWritePolicy, +} from "../../../core"; +import { + MockedProvider, + MockedProviderProps, + MockedResponse, + MockLink, + MockSubscriptionLink, + wait, +} from "../../../testing"; +import { + concatPagination, + offsetLimitPagination, + DeepPartial, +} from "../../../utilities"; +import { useLoadableQuery } from "../useLoadableQuery"; +import type { UseReadQueryResult } from "../useReadQuery"; +import { useReadQuery } from "../useReadQuery"; +import { ApolloProvider } from "../../context"; +import { InMemoryCache } from "../../../cache"; +import { LoadableQueryHookFetchPolicy } from "../../types/types"; +import { QueryReference } from "../../../react"; +import { FetchMoreFunction, RefetchFunction } from "../useSuspenseQuery"; +import invariant, { InvariantError } from "ts-invariant"; +import { + Profiler, + createProfiler, + spyOnConsole, + useTrackRenders, +} from "../../../testing/internal"; + +interface SimpleQueryData { + greeting: string; +} + +function useSimpleQueryCase() { + const query: TypedDocumentNode = gql` + query GreetingQuery { + greeting + } + `; + + const mocks: MockedResponse[] = [ + { + request: { query }, + result: { data: { greeting: "Hello" } }, + delay: 10, + }, + ]; + + return { query, mocks }; +} + +interface VariablesCaseData { + character: { + id: string; + name: string; + }; +} + +interface VariablesCaseVariables { + id: string; +} + +function useVariablesQueryCase() { + const query: TypedDocumentNode< + VariablesCaseData, + VariablesCaseVariables + > = gql` + query CharacterQuery($id: ID!) { + character(id: $id) { + id + name + } + } + `; + const CHARACTERS = ["Spider-Man", "Black Widow", "Iron Man", "Hulk"]; + + const mocks: MockedResponse[] = [...CHARACTERS].map( + (name, index) => ({ + request: { query, variables: { id: String(index + 1) } }, + result: { data: { character: { id: String(index + 1), name } } }, + delay: 20, + }) + ); + + return { mocks, query }; +} + +interface PaginatedQueryData { + letters: { + letter: string; + position: number; + }[]; +} + +interface PaginatedQueryVariables { + limit?: number; + offset?: number; +} + +function usePaginatedQueryCase() { + const query: TypedDocumentNode< + PaginatedQueryData, + PaginatedQueryVariables + > = gql` + query letters($limit: Int, $offset: Int) { + letters(limit: $limit) { + letter + position + } + } + `; + + const data = "ABCDEFG" + .split("") + .map((letter, index) => ({ letter, position: index + 1 })); + + const link = new ApolloLink((operation) => { + const { offset = 0, limit = 2 } = operation.variables; + const letters = data.slice(offset, offset + limit); + + return new Observable((observer) => { + setTimeout(() => { + observer.next({ data: { letters } }); + observer.complete(); + }, 10); + }); + }); + + const client = new ApolloClient({ cache: new InMemoryCache(), link }); + + return { query, link, client }; +} + +function createDefaultProfiler() { + return createProfiler({ + initialSnapshot: { + error: null as Error | null, + result: null as UseReadQueryResult | null, + }, + }); +} + +function createDefaultProfiledComponents< + Snapshot extends { + result: UseReadQueryResult | null; + error?: Error | null; + }, + TData = Snapshot["result"] extends UseReadQueryResult | null + ? TData + : unknown, +>(profiler: Profiler) { + function SuspenseFallback() { + useTrackRenders(); + return

Loading

; + } + + function ReadQueryHook({ queryRef }: { queryRef: QueryReference }) { + useTrackRenders(); + profiler.mergeSnapshot({ + result: useReadQuery(queryRef), + } as Partial); + + return null; + } + + function ErrorFallback({ error }: { error: Error }) { + useTrackRenders(); + profiler.mergeSnapshot({ error } as Partial); + + return
Oops
; + } + + function ErrorBoundary({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + + return { + SuspenseFallback, + ReadQueryHook, + ErrorFallback, + ErrorBoundary, + }; +} + +function renderWithMocks( + ui: React.ReactElement, + { + wrapper: Wrapper = React.Fragment, + ...props + }: MockedProviderProps & { wrapper?: RenderOptions["wrapper"] } +) { + const user = userEvent.setup(); + + const utils = render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + return { ...utils, user }; +} + +function renderWithClient( + ui: React.ReactElement, + options: { client: ApolloClient; wrapper?: RenderOptions["wrapper"] } +) { + const { client, wrapper: Wrapper = React.Fragment } = options; + const user = userEvent.setup(); + + const utils = render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + return { ...utils, user }; +} + +it("loads a query and suspends when the load query function is called", async () => { + const { query, mocks } = useSimpleQueryCase(); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + } + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + } +}); + +it("loads a query with variables and suspends by passing variables to the loadQuery function", async () => { + const { query, mocks } = useVariablesQueryCase(); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + } + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("changes variables on a query and resuspends when passing new variables to the loadQuery function", async () => { + const { query, mocks } = useVariablesQueryCase(); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const App = () => { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + }; + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + } + + await act(() => user.click(screen.getByText("Load 1st character"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await act(() => user.click(screen.getByText("Load 2nd character"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "2", name: "Black Widow" } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("resets the `queryRef` to null and disposes of it when calling the `reset` function", async () => { + const { query, mocks } = useSimpleQueryCase(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { reset }] = useLoadableQuery(query); + + // Resetting the result allows us to detect when ReadQueryHook is unmounted + // since it won't render and overwrite the `null` + Profiler.mergeSnapshot({ result: null }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Reset query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toBeNull(); + } + + // Since dispose is called in a setTimeout, we need to wait a tick before + // checking to see if the query ref was properly disposed + await wait(0); + + expect(client.getObservableQueries().size).toBe(0); +}); + +it("allows the client to be overridden", async () => { + const { query } = useSimpleQueryCase(); + + const globalClient = new ApolloClient({ + link: new ApolloLink(() => + Observable.of({ data: { greeting: "global hello" } }) + ), + cache: new InMemoryCache(), + }); + + const localClient = new ApolloClient({ + link: new ApolloLink(() => + Observable.of({ data: { greeting: "local hello" } }) + ), + cache: new InMemoryCache(), + }); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef] = useLoadableQuery(query, { + client: localClient, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { + client: globalClient, + wrapper: Profiler, + }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "local hello" }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); +}); + +it("passes context to the link", async () => { + interface QueryData { + context: Record; + } + + const query: TypedDocumentNode = gql` + query ContextQuery { + context + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink((operation) => { + return new Observable((observer) => { + const { valueA, valueB } = operation.getContext(); + + observer.next({ data: { context: { valueA, valueB } } }); + observer.complete(); + }); + }), + }); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef] = useLoadableQuery(query, { + context: { valueA: "A", valueB: "B" }, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { context: { valueA: "A", valueB: "B" } }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); +}); + +it('enables canonical results when canonizeResults is "true"', async () => { + interface Result { + __typename: string; + value: number; + } + + interface QueryData { + results: Result[]; + } + + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: "Result", value: 0 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 2 }, + { __typename: "Result", value: 3 }, + { __typename: "Result", value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }); + + const client = new ApolloClient({ + cache, + link: new MockLink([]), + }); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef] = useLoadableQuery(query, { + canonizeResults: true, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + const { snapshot } = await Profiler.takeRender(); + const resultSet = new Set(snapshot.result?.data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(snapshot.result).toEqual({ + data: { results }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + expect(resultSet.size).toBe(5); + expect(values).toEqual([0, 1, 2, 3, 5]); +}); + +it("can disable canonical results when the cache's canonizeResults setting is true", async () => { + interface Result { + __typename: string; + value: number; + } + + interface QueryData { + results: Result[]; + } + + const cache = new InMemoryCache({ + canonizeResults: true, + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode<{ results: Result[] }, never> = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: "Result", value: 0 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 2 }, + { __typename: "Result", value: 3 }, + { __typename: "Result", value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef] = useLoadableQuery(query, { + canonizeResults: false, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { cache, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + const { snapshot } = await Profiler.takeRender(); + const resultSet = new Set(snapshot.result!.data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(snapshot.result).toEqual({ + data: { results }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + expect(resultSet.size).toBe(6); + expect(values).toEqual([0, 1, 1, 2, 3, 5]); +}); + +it("returns initial cache data followed by network data when the fetch policy is `cache-and-network`", async () => { + type QueryData = { hello: string }; + const query: TypedDocumentNode = gql` + query { + hello + } + `; + const cache = new InMemoryCache(); + const link = new MockLink([ + { + request: { query }, + result: { data: { hello: "from link" } }, + delay: 20, + }, + ]); + + const client = new ApolloClient({ link, cache }); + + cache.writeQuery({ query, data: { hello: "from cache" } }); + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "cache-and-network", + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { hello: "from cache" }, + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { hello: "from link" }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } +}); + +it("all data is present in the cache, no network request is made", async () => { + const query = gql` + query { + hello + } + `; + const cache = new InMemoryCache(); + const link = new MockLink([ + { + request: { query }, + result: { data: { hello: "from link" } }, + delay: 20, + }, + ]); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: "from cache" } }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { hello: "from cache" }, + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(Profiler).not.toRerender(); +}); + +it("partial data is present in the cache so it is ignored and network request is made", async () => { + const query = gql` + { + hello + foo + } + `; + const cache = new InMemoryCache(); + const link = new MockLink([ + { + request: { query }, + result: { data: { hello: "from link", foo: "bar" } }, + delay: 20, + }, + ]); + + const client = new ApolloClient({ + link, + cache, + }); + + { + // we expect a "Missing field 'foo' while writing result..." error + // when writing hello to the cache, so we'll silence the console.error + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ query, data: { hello: "from cache" } }); + } + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { foo: "bar", hello: "from link" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it("existing data in the cache is ignored when `fetchPolicy` is 'network-only'", async () => { + const query = gql` + query { + hello + } + `; + const cache = new InMemoryCache(); + const link = new MockLink([ + { + request: { query }, + result: { data: { hello: "from link" } }, + delay: 20, + }, + ]); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: "from cache" } }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "network-only", + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { hello: "from link" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it("fetches data from the network but does not update the cache when `fetchPolicy` is 'no-cache'", async () => { + const query = gql` + query { + hello + } + `; + const cache = new InMemoryCache(); + const link = new MockLink([ + { + request: { query }, + result: { data: { hello: "from link" } }, + delay: 20, + }, + ]); + + const client = new ApolloClient({ link, cache }); + + cache.writeQuery({ query, data: { hello: "from cache" } }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "no-cache", + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { hello: "from link" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(client.extract()).toEqual({ + ROOT_QUERY: { __typename: "Query", hello: "from cache" }, + }); +}); + +it("works with startTransition to change variables", async () => { + type Variables = { + id: string; + }; + + interface Data { + todo: { + id: string; + name: string; + completed: boolean; + }; + } + + const query: TypedDocumentNode = gql` + query TodoItemQuery($id: ID!) { + todo(id: $id) { + id + name + completed + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { todo: { id: "1", name: "Clean room", completed: false } }, + }, + delay: 10, + }, + { + request: { query, variables: { id: "2" } }, + result: { + data: { + todo: { id: "2", name: "Take out trash", completed: true }, + }, + }, + delay: 10, + }, + ]; + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + function SuspenseFallback() { + return

Loading

; + } + + function App() { + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( +
+ + }> + {queryRef && ( + loadQuery({ id })} /> + )} + +
+ ); + } + + function Todo({ + queryRef, + onChange, + }: { + queryRef: QueryReference; + onChange: (id: string) => void; + }) { + const { data } = useReadQuery(queryRef); + const [isPending, startTransition] = React.useTransition(); + const { todo } = data; + + return ( + <> + +
+ {todo.name} + {todo.completed && " (completed)"} +
+ + ); + } + + const { user } = renderWithClient(, { client }); + + await act(() => user.click(screen.getByText("Load first todo"))); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + expect(await screen.findByTestId("todo")).toBeInTheDocument(); + + const todo = screen.getByTestId("todo"); + const button = screen.getByText("Refresh"); + + expect(todo).toHaveTextContent("Clean room"); + + await act(() => user.click(button)); + + // startTransition will avoid rendering the suspense fallback for already + // revealed content if the state update inside the transition causes the + // component to suspend. + // + // Here we should not see the suspense fallback while the component suspends + // until the todo is finished loading. Seeing the suspense fallback is an + // indication that we are suspending the component too late in the process. + expect(screen.queryByText("Loading")).not.toBeInTheDocument(); + + // We can ensure this works with isPending from useTransition in the process + expect(todo).toHaveAttribute("aria-busy", "true"); + + // Ensure we are showing the stale UI until the new todo has loaded + expect(todo).toHaveTextContent("Clean room"); + + // Eventually we should see the updated todo content once its done + // suspending. + await waitFor(() => { + expect(todo).toHaveTextContent("Take out trash (completed)"); + }); +}); + +it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ cache, link }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "cache-and-network", + }); + return ( +
+ + }> + {queryRef && } + +
+ ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load todo"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + link.simulateResult({ + result: { + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + link.simulateResult( + { + result: { + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }, + }, + true + ); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("reacts to cache updates", async () => { + const { query, mocks } = useSimpleQueryCase(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const Profiler = createProfiler({ + initialSnapshot: { + result: null as UseReadQueryResult | null, + }, + }); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + client.writeQuery({ + query, + data: { greeting: "Updated Hello" }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Updated Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("applies `errorPolicy` on next fetch when it changes between renders", async () => { + const { query } = useSimpleQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query }, + result: { data: { greeting: "Hello" } }, + }, + { + request: { query }, + result: { + errors: [new GraphQLError("oops")], + }, + }, + ]; + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook, ErrorBoundary, ErrorFallback } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [errorPolicy, setErrorPolicy] = useState("none"); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + errorPolicy, + }); + + return ( + <> + + + + }> + + {queryRef && } + + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Change error policy"))); + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Refetch greeting"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + // Ensure we aren't rendering the error boundary and instead rendering the + // error message in the hook component. + expect(renderedComponents).not.toContain(ErrorFallback); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: new ApolloError({ graphQLErrors: [new GraphQLError("oops")] }), + networkStatus: NetworkStatus.error, + }); + } +}); + +it("applies `context` on next fetch when it changes between renders", async () => { + interface Data { + phase: string; + } + + const query: TypedDocumentNode = gql` + query { + phase + } + `; + + const link = new ApolloLink((operation) => { + return Observable.of({ + data: { + phase: operation.getContext().phase, + }, + }); + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [phase, setPhase] = React.useState("initial"); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + context: { phase }, + }); + + return ( + <> + + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result!.data).toEqual({ + phase: "initial", + }); + } + + await act(() => user.click(screen.getByText("Update context"))); + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result!.data).toEqual({ + phase: "rerender", + }); + } +}); + +// NOTE: We only test the `false` -> `true` path here. If the option changes +// from `true` -> `false`, the data has already been canonized, so it has no +// effect on the output. +it("returns canonical results immediately when `canonizeResults` changes from `false` to `true` between renders", async () => { + interface Result { + __typename: string; + value: number; + } + + interface Data { + results: Result[]; + } + + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: "Result", value: 0 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 1 }, + { __typename: "Result", value: 2 }, + { __typename: "Result", value: 3 }, + { __typename: "Result", value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }); + + const client = new ApolloClient({ + link: new MockLink([]), + cache, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [canonizeResults, setCanonizeResults] = React.useState(false); + const [loadQuery, queryRef] = useLoadableQuery(query, { + canonizeResults, + }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot } = await Profiler.takeRender(); + const { data } = snapshot.result!; + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(6); + expect(values).toEqual([0, 1, 1, 2, 3, 5]); + } + + await act(() => user.click(screen.getByText("Canonize results"))); + + { + const { snapshot } = await Profiler.takeRender(); + const { data } = snapshot.result!; + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(5); + expect(values).toEqual([0, 1, 2, 3, 5]); + } +}); + +it("applies changed `refetchWritePolicy` to next fetch when changing between renders", async () => { + interface Data { + primes: number[]; + } + + const query: TypedDocumentNode = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + { + request: { query, variables: { min: 30, max: 50 } }, + result: { data: { primes: [31, 37, 41, 43, 47] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [refetchWritePolicy, setRefetchWritePolicy] = + React.useState("merge"); + + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + refetchWritePolicy, + }); + + return ( + <> + + + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + const { primes } = snapshot.result!.data; + + expect(primes).toEqual([2, 3, 5, 7, 11]); + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + } + + await act(() => user.click(screen.getByText("Refetch next"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + const { primes } = snapshot.result!.data; + + expect(primes).toEqual([2, 3, 5, 7, 11, 13, 17, 19, 23, 29]); + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + ]); + } + + await act(() => user.click(screen.getByText("Change refetch write policy"))); + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Refetch last"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + const { primes } = snapshot.result!.data; + + expect(primes).toEqual([31, 37, 41, 43, 47]); + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + [undefined, [31, 37, 41, 43, 47]], + ]); + } +}); + +it("applies `returnPartialData` on next fetch when it changes between renders", async () => { + interface Data { + character: { + __typename: "Character"; + id: string; + name: string; + }; + } + + interface PartialData { + character: { + __typename: "Character"; + id: string; + }; + } + + const fullQuery: TypedDocumentNode = gql` + query { + character { + __typename + id + name + } + } + `; + + const partialQuery: TypedDocumentNode = gql` + query { + character { + __typename + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strange", + }, + }, + }, + }, + { + request: { query: fullQuery }, + result: { + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strange (refetched)", + }, + }, + }, + delay: 100, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { __typename: "Character", id: "1" } }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [returnPartialData, setReturnPartialData] = React.useState(false); + + const [loadQuery, queryRef] = useLoadableQuery(fullQuery, { + returnPartialData, + }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + character: { __typename: "Character", id: "1", name: "Doctor Strange" }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Update partial data"))); + await Profiler.takeRender(); + + cache.modify({ + id: cache.identify({ __typename: "Character", id: "1" }), + fields: { + name: (_, { DELETE }) => DELETE, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + character: { __typename: "Character", id: "1" }, + }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strange (refetched)", + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it("applies updated `fetchPolicy` on next fetch when it changes between renders", async () => { + interface Data { + character: { + __typename: "Character"; + id: string; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query { + character { + __typename + id + name + } + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strange", + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strangecache", + }, + }, + }); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [fetchPolicy, setFetchPolicy] = + React.useState("cache-first"); + + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + fetchPolicy, + }); + + return ( + <> + + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strangecache", + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Change fetch policy"))); + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + character: { + __typename: "Character", + id: "1", + name: "Doctor Strange", + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + // Because we switched to a `no-cache` fetch policy, we should not see the + // newly fetched data in the cache after the fetch occured. + expect(cache.readQuery({ query })).toEqual({ + character: { + __typename: "Character", + id: "1", + name: "Doctor Strangecache", + }, + }); +}); + +it("re-suspends when calling `refetch`", async () => { + const { query } = useVariablesQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Spider-Man" } }, + }, + delay: 20, + }, + // refetch + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Spider-Man (updated)" } }, + }, + delay: 20, + }, + ]; + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man (updated)" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("re-suspends when calling `refetch` with new variables", async () => { + const { query } = useVariablesQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Captain Marvel" } }, + }, + }, + { + request: { query, variables: { id: "2" } }, + result: { + data: { character: { id: "2", name: "Captain America" } }, + }, + }, + ]; + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch with ID 2"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "2", name: "Captain America" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("re-suspends multiple times when calling `refetch` multiple times", async () => { + const { query } = useVariablesQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Spider-Man" } }, + }, + maxUsageCount: 3, + }, + ]; + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + const button = screen.getByText("Refetch"); + + await act(() => user.click(button)); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(button)); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("throws errors when errors are returned after calling `refetch`", async () => { + using _consoleSpy = spyOnConsole("error"); + + const { query } = useVariablesQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Captain Marvel" } }, + }, + delay: 20, + }, + { + request: { query, variables: { id: "1" } }, + result: { + errors: [new GraphQLError("Something went wrong")], + }, + delay: 20, + }, + ]; + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook, ErrorBoundary, ErrorFallback } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + + }> + + {queryRef && } + + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ErrorFallback]); + expect(snapshot.error).toEqual( + new ApolloError({ + graphQLErrors: [new GraphQLError("Something went wrong")], + }) + ); + } +}); + +it('ignores errors returned after calling `refetch` when errorPolicy is set to "ignore"', async () => { + const { query } = useVariablesQueryCase(); + + const mocks = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Captain Marvel" } }, + }, + }, + { + request: { query, variables: { id: "1" } }, + result: { + errors: [new GraphQLError("Something went wrong")], + }, + }, + ]; + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook, ErrorBoundary, ErrorFallback } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + errorPolicy: "ignore", + }); + + return ( + <> + + + }> + + {queryRef && } + + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toStrictEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.error).toBeNull(); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + + expect(renderedComponents).not.toContain(ErrorFallback); + } + + await expect(Profiler).not.toRerender(); +}); + +it('returns errors after calling `refetch` when errorPolicy is set to "all"', async () => { + const { query } = useVariablesQueryCase(); + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Captain Marvel" } }, + }, + delay: 20, + }, + { + request: { query, variables: { id: "1" } }, + result: { + errors: [new GraphQLError("Something went wrong")], + }, + delay: 20, + }, + ]; + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook, ErrorBoundary, ErrorFallback } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + errorPolicy: "all", + }); + + return ( + <> + + + }> + + {queryRef && } + + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).not.toContain(ErrorFallback); + expect(snapshot.error).toBeNull(); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: new ApolloError({ + graphQLErrors: [new GraphQLError("Something went wrong")], + }), + networkStatus: NetworkStatus.error, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it('handles partial data results after calling `refetch` when errorPolicy is set to "all"', async () => { + const { query } = useVariablesQueryCase(); + + const mocks = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: "Captain Marvel" } }, + }, + delay: 20, + }, + { + request: { query, variables: { id: "1" } }, + result: { + data: { character: { id: "1", name: null } }, + errors: [new GraphQLError("Something went wrong")], + }, + delay: 20, + }, + ]; + + const Profiler = createDefaultProfiler(); + + const { SuspenseFallback, ReadQueryHook, ErrorBoundary, ErrorFallback } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + errorPolicy: "all", + }); + + return ( + <> + + + }> + + {queryRef && } + + + + ); + } + + const { user } = renderWithMocks(, { mocks, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Captain Marvel" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).not.toContain(ErrorFallback); + expect(snapshot.error).toBeNull(); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: null } }, + error: new ApolloError({ + graphQLErrors: [new GraphQLError("Something went wrong")], + }), + networkStatus: NetworkStatus.error, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("`refetch` works with startTransition to allow React to show stale UI until finished suspending", async () => { + type Variables = { + id: string; + }; + + interface Data { + todo: { + id: string; + name: string; + completed: boolean; + }; + } + + const query: TypedDocumentNode = gql` + query TodoItemQuery($id: ID!) { + todo(id: $id) { + id + name + completed + } + } + `; + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { id: "1" } }, + result: { + data: { todo: { id: "1", name: "Clean room", completed: false } }, + }, + delay: 10, + }, + { + request: { query, variables: { id: "1" } }, + result: { + data: { todo: { id: "1", name: "Clean room", completed: true } }, + }, + delay: 10, + }, + ]; + + function SuspenseFallback() { + return

Loading

; + } + + function App() { + const [id, setId] = React.useState("1"); + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && ( + + )} + + + ); + } + + function Todo({ + queryRef, + refetch, + }: { + refetch: RefetchFunction; + queryRef: QueryReference; + onChange: (id: string) => void; + }) { + const { data } = useReadQuery(queryRef); + const [isPending, startTransition] = React.useTransition(); + const { todo } = data; + + return ( + <> + +
+ {todo.name} + {todo.completed && " (completed)"} +
+ + ); + } + + const { user } = renderWithMocks(, { mocks }); + + await act(() => user.click(screen.getByText("Load query"))); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + expect(await screen.findByTestId("todo")).toBeInTheDocument(); + + const todo = screen.getByTestId("todo"); + const button = screen.getByText("Refresh"); + + expect(todo).toHaveTextContent("Clean room"); + + await act(() => user.click(button)); + + // startTransition will avoid rendering the suspense fallback for already + // revealed content if the state update inside the transition causes the + // component to suspend. + // + // Here we should not see the suspense fallback while the component suspends + // until the todo is finished loading. Seeing the suspense fallback is an + // indication that we are suspending the component too late in the process. + expect(screen.queryByText("Loading")).not.toBeInTheDocument(); + + // We can ensure this works with isPending from useTransition in the process + expect(todo).toHaveAttribute("aria-busy", "true"); + + // Ensure we are showing the stale UI until the new todo has loaded + expect(todo).toHaveTextContent("Clean room"); + + // Eventually we should see the updated todo content once its done + // suspending. + await waitFor(() => { + expect(todo).toHaveTextContent("Clean room (completed)"); + }); +}); + +it("re-suspends when calling `fetchMore` with different variables", async () => { + const { query, client } = usePaginatedQueryCase(); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "A", position: 1 }, + { letter: "B", position: 2 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Fetch more"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "C", position: 3 }, + { letter: "D", position: 4 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("properly uses `updateQuery` when calling `fetchMore`", async () => { + const { query, client } = usePaginatedQueryCase(); + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "A", position: 1 }, + { letter: "B", position: 2 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Fetch more"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "A", position: 1 }, + { letter: "B", position: 2 }, + { letter: "C", position: 3 }, + { letter: "D", position: 4 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("properly uses cache field policies when calling `fetchMore` without `updateQuery`", async () => { + const { query, link } = usePaginatedQueryCase(); + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + letters: concatPagination(), + }, + }, + }, + }), + }); + + function App() { + const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "A", position: 1 }, + { letter: "B", position: 2 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Fetch more"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { + letters: [ + { letter: "A", position: 1 }, + { letter: "B", position: 2 }, + { letter: "C", position: 3 }, + { letter: "D", position: 4 }, + ], + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("`fetchMore` works with startTransition to allow React to show stale UI until finished suspending", async () => { + type Variables = { + offset: number; + }; + + interface Todo { + __typename: "Todo"; + id: string; + name: string; + completed: boolean; + } + interface Data { + todos: Todo[]; + } + + const query: TypedDocumentNode = gql` + query TodosQuery($offset: Int!) { + todos(offset: $offset) { + id + name + completed + } + } + `; + + const mocks: MockedResponse[] = [ + { + request: { query, variables: { offset: 0 } }, + result: { + data: { + todos: [ + { + __typename: "Todo", + id: "1", + name: "Clean room", + completed: false, + }, + ], + }, + }, + delay: 10, + }, + { + request: { query, variables: { offset: 1 } }, + result: { + data: { + todos: [ + { + __typename: "Todo", + id: "2", + name: "Take out trash", + completed: true, + }, + ], + }, + }, + delay: 10, + }, + ]; + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + todos: offsetLimitPagination(), + }, + }, + }, + }), + }); + + function SuspenseFallback() { + return

Loading

; + } + + function App() { + const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + function Todo({ + queryRef, + fetchMore, + }: { + fetchMore: FetchMoreFunction; + queryRef: QueryReference; + }) { + const { data } = useReadQuery(queryRef); + const [isPending, startTransition] = React.useTransition(); + const { todos } = data; + + return ( + <> + +
+ {todos.map((todo) => ( +
+ {todo.name} + {todo.completed && " (completed)"} +
+ ))} +
+ + ); + } + + const { user } = renderWithClient(, { client }); + + await act(() => user.click(screen.getByText("Load query"))); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + + expect(await screen.findByTestId("todos")).toBeInTheDocument(); + + const todos = screen.getByTestId("todos"); + const todo1 = screen.getByTestId("todo:1"); + const button = screen.getByText("Load more"); + + expect(todo1).toBeInTheDocument(); + + await act(() => user.click(button)); + + // startTransition will avoid rendering the suspense fallback for already + // revealed content if the state update inside the transition causes the + // component to suspend. + // + // Here we should not see the suspense fallback while the component suspends + // until the todo is finished loading. Seeing the suspense fallback is an + // indication that we are suspending the component too late in the process. + expect(screen.queryByText("Loading")).not.toBeInTheDocument(); + + // We can ensure this works with isPending from useTransition in the process + expect(todos).toHaveAttribute("aria-busy", "true"); + + // Ensure we are showing the stale UI until the new todo has loaded + expect(todo1).toHaveTextContent("Clean room"); + + // Eventually we should see the updated todos content once its done + // suspending. + await waitFor(() => { + expect(screen.getByTestId("todo:2")).toHaveTextContent( + "Take out trash (completed)" + ); + expect(todo1).toHaveTextContent("Clean room"); + }); +}); + +it('honors refetchWritePolicy set to "merge"', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + interface QueryData { + primes: number[]; + } + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { + refetchWritePolicy: "merge", + }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { primes: [2, 3, 5, 7, 11] }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + ]); + } + + await expect(Profiler).not.toRerender(); +}); + +it('defaults refetchWritePolicy to "overwrite"', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + interface QueryData { + primes: number[]; + } + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache, + }); + + function App() { + const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial load + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { primes: [2, 3, 5, 7, 11] }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + } + + await act(() => user.click(screen.getByText("Refetch"))); + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { primes: [13, 17, 19, 23, 29] }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [undefined, [13, 17, 19, 23, 29]], + ]); + } +}); + +it('does not suspend when partial data is in the cache and using a "cache-first" fetch policy with returnPartialData', async () => { + interface Data { + character: { + id: string; + name: string; + }; + } + + const fullQuery: TypedDocumentNode = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + delay: 20, + }, + ]; + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + }); + + const client = new ApolloClient({ link: new MockLink(mocks), cache }); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(fullQuery, { + fetchPolicy: "cache-first", + returnPartialData: true, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial load + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1" } }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Doctor Strange" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + } + + await expect(Profiler).not.toRerender(); +}); + +it('suspends and does not use partial data from other variables in the cache when changing variables and using a "cache-first" fetch policy with returnPartialData: true', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const partialQuery = gql` + query ($id: ID!) { + character(id: $id) { + id + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + variables: { id: "1" }, + }); + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { + mocks, + cache, + wrapper: Profiler, + }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1" } }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + } + + await act(() => user.click(screen.getByText("Change variables"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "2", name: "Black Widow" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + } + + await expect(Profiler).not.toRerender(); +}); + +it('suspends when partial data is in the cache and using a "network-only" fetch policy with returnPartialData', async () => { + interface Data { + character: { + id: string; + name: string; + }; + } + + const fullQuery: TypedDocumentNode = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + }, + ]; + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + }); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(fullQuery, { + fetchPolicy: "network-only", + returnPartialData: true, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { + mocks, + cache, + wrapper: Profiler, + }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Doctor Strange" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it('suspends when partial data is in the cache and using a "no-cache" fetch policy with returnPartialData', async () => { + using _consoleSpy = spyOnConsole("warn"); + + interface Data { + character: { + id: string; + name: string; + }; + } + + const fullQuery: TypedDocumentNode = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + }); + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(fullQuery, { + fetchPolicy: "no-cache", + returnPartialData: true, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { + mocks, + cache, + wrapper: Profiler, + }); + + // initial load + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Doctor Strange" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it('warns when using returnPartialData with a "no-cache" fetch policy', async () => { + using _consoleSpy = spyOnConsole("warn"); + + const query: TypedDocumentNode = gql` + query UserQuery { + greeting + } + `; + + renderHook( + () => + useLoadableQuery(query, { + fetchPolicy: "no-cache", + returnPartialData: true, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Using `returnPartialData` with a `no-cache` fetch policy has no effect. To read partial data from the cache, consider using an alternate fetch policy." + ); +}); + +it('does not suspend when partial data is in the cache and using a "cache-and-network" fetch policy with returnPartialData', async () => { + interface Data { + character: { + id: string; + name: string; + }; + } + + const fullQuery: TypedDocumentNode = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + delay: 20, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + }); + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(fullQuery, { + fetchPolicy: "cache-and-network", + returnPartialData: true, + }); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { + mocks, + cache, + wrapper: Profiler, + }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "1" } }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Doctor Strange" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it('suspends and does not use partial data when changing variables and using a "cache-and-network" fetch policy with returnPartialData', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const partialQuery = gql` + query ($id: ID!) { + character(id: $id) { + id + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + variables: { id: "1" }, + }); + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef] = useLoadableQuery(query, { + fetchPolicy: "cache-and-network", + returnPartialData: true, + }); + + return ( + <> + + + }> + {queryRef && } + + + ); + } + + const { user } = renderWithMocks(, { + mocks, + cache, + wrapper: Profiler, + }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "1" } }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { character: { id: "1", name: "Spider-Man" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(screen.getByText("Change variables"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { character: { id: "2", name: "Black Widow" } }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); + +it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + + { + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + using _consoleSpy = spyOnConsole("error"); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ link, cache }); + + const Profiler = createDefaultProfiler>(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadTodo, queryRef] = useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }); + + return ( +
+ + }> + {queryRef && } + +
+ ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load todo"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + link.simulateResult({ + result: { + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + link.simulateResult( + { + result: { + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }, + }, + true + ); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender(); +}); + +it("throws when calling loadQuery on first render", async () => { + using _consoleSpy = spyOnConsole("error"); + const { query, mocks } = useSimpleQueryCase(); + + function App() { + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + + return null; + } + + expect(() => renderWithMocks(, { mocks })).toThrow( + new InvariantError( + "useLoadableQuery: 'loadQuery' should not be called during render. To start a query during render, use the 'useBackgroundQuery' hook." + ) + ); +}); + +it("throws when calling loadQuery on subsequent render", async () => { + using _consoleSpy = spyOnConsole("error"); + const { query, mocks } = useSimpleQueryCase(); + + let error!: Error; + + function App() { + const [count, setCount] = useState(0); + const [loadQuery] = useLoadableQuery(query); + + if (count === 1) { + loadQuery(); + } + + return ; + } + + const { user } = renderWithMocks( + (error = e)} fallback={
Oops
}> + +
, + { mocks } + ); + + await act(() => user.click(screen.getByText("Load query in render"))); + + expect(error).toEqual( + new InvariantError( + "useLoadableQuery: 'loadQuery' should not be called during render. To start a query during render, use the 'useBackgroundQuery' hook." + ) + ); +}); + +it("allows loadQuery to be called in useEffect on first render", async () => { + const { query, mocks } = useSimpleQueryCase(); + + function App() { + const [loadQuery] = useLoadableQuery(query); + + React.useEffect(() => { + loadQuery(); + }, []); + + return null; + } + + expect(() => renderWithMocks(, { mocks })).not.toThrow(); +}); + +describe.skip("type tests", () => { + it("returns unknown when TData cannot be inferred", () => { + const query = gql``; + + const [, queryRef] = useLoadableQuery(query); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + }); + + it("variables are optional and can be anything with an untyped DocumentNode", () => { + const query = gql``; + + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + loadQuery({}); + loadQuery({ foo: "bar" }); + loadQuery({ bar: "baz" }); + }); + + it("variables are optional and can be anything with unspecified TVariables on a TypedDocumentNode", () => { + const query: TypedDocumentNode<{ greeting: string }> = gql``; + + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + loadQuery({}); + loadQuery({ foo: "bar" }); + loadQuery({ bar: "baz" }); + }); + + it("variables are optional when TVariables are empty", () => { + const query: TypedDocumentNode< + { greeting: string }, + Record + > = gql``; + + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + loadQuery({}); + // @ts-expect-error unknown variable + loadQuery({ foo: "bar" }); + }); + + it("does not allow variables when TVariables is `never`", () => { + const query: TypedDocumentNode<{ greeting: string }, never> = gql``; + + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + // @ts-expect-error no variables argument allowed + loadQuery({}); + // @ts-expect-error no variables argument allowed + loadQuery({ foo: "bar" }); + }); + + it("optional variables are optional to loadQuery", () => { + const query: TypedDocumentNode< + { posts: string[] }, + { limit?: number } + > = gql``; + + const [loadQuery] = useLoadableQuery(query); + + loadQuery(); + loadQuery({}); + loadQuery({ limit: 10 }); + loadQuery({ + // @ts-expect-error unknown variable + foo: "bar", + }); + loadQuery({ + limit: 10, + // @ts-expect-error unknown variable + foo: "bar", + }); + }); + + it("enforces required variables when TVariables includes required variables", () => { + const query: TypedDocumentNode< + { character: string }, + { id: string } + > = gql``; + + const [loadQuery] = useLoadableQuery(query); + + // @ts-expect-error missing variables argument + loadQuery(); + // @ts-expect-error empty variables + loadQuery({}); + loadQuery({ id: "1" }); + loadQuery({ + // @ts-expect-error unknown variable + foo: "bar", + }); + loadQuery({ + id: "1", + // @ts-expect-error unknown variable + foo: "bar", + }); + }); + + it("requires variables with mixed TVariables", () => { + const query: TypedDocumentNode< + { character: string }, + { id: string; language?: string } + > = gql``; + + const [loadQuery] = useLoadableQuery(query); + + // @ts-expect-error missing variables argument + loadQuery(); + // @ts-expect-error empty variables + loadQuery({}); + loadQuery({ id: "1" }); + // @ts-expect-error missing required variable + loadQuery({ language: "en" }); + loadQuery({ id: "1", language: "en" }); + loadQuery({ + // @ts-expect-error unknown variable + foo: "bar", + }); + loadQuery({ + id: "1", + // @ts-expect-error unknown variable + foo: "bar", + }); + loadQuery({ + id: "1", + language: "en", + // @ts-expect-error unknown variable + foo: "bar", + }); + }); + + it("returns TData in default case", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it('returns TData | undefined with errorPolicy: "ignore"', () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + errorPolicy: "ignore", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { errorPolicy: "ignore" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it('returns TData | undefined with errorPolicy: "all"', () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + errorPolicy: "all", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { errorPolicy: "all" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it('returns TData with errorPolicy: "none"', () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + errorPolicy: "none", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { errorPolicy: "none" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it("returns DeepPartial with returnPartialData: true", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + returnPartialData: true, + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { returnPartialData: true }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); + + it("returns TData with returnPartialData: false", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + returnPartialData: false, + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { returnPartialData: false }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it("returns TData when passing an option that does not affect TData", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + fetchPolicy: "no-cache", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { fetchPolicy: "no-cache" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf(); + } + }); + + it("handles combinations of options", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + returnPartialData: true, + errorPolicy: "ignore", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf< + DeepPartial | undefined + >(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { returnPartialData: true, errorPolicy: "ignore" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf< + DeepPartial | undefined + >(); + } + + { + const [, queryRef] = useLoadableQuery(query, { + returnPartialData: true, + errorPolicy: "none", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { returnPartialData: true, errorPolicy: "none" }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); + + it("returns correct TData type when combined options that do not affect TData", () => { + const { query } = useVariablesQueryCase(); + + { + const [, queryRef] = useLoadableQuery(query, { + fetchPolicy: "no-cache", + returnPartialData: true, + errorPolicy: "none", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const [, queryRef] = useLoadableQuery< + VariablesCaseData, + VariablesCaseVariables + >(query, { + fetchPolicy: "no-cache", + returnPartialData: true, + errorPolicy: "none", + }); + + invariant(queryRef); + + const { data } = useReadQuery(queryRef); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 468fe8f4fc5..642be7d023a 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -357,7 +357,7 @@ describe("useSuspenseQuery", () => { const Component = () => { const result = useSuspenseQuery(query); - ProfiledApp.updateSnapshot(result); + ProfiledApp.replaceSnapshot(result); return
{result.data.greeting}
; }; diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index 61d50665cac..8a725261f40 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -11,6 +11,11 @@ export type { UseSuspenseQueryResult } from "./useSuspenseQuery.js"; export { useSuspenseQuery } from "./useSuspenseQuery.js"; export type { UseBackgroundQueryResult } from "./useBackgroundQuery.js"; export { useBackgroundQuery } from "./useBackgroundQuery.js"; +export type { + LoadQueryFunction, + UseLoadableQueryResult, +} from "./useLoadableQuery.js"; +export { useLoadableQuery } from "./useLoadableQuery.js"; export type { UseReadQueryResult } from "./useReadQuery.js"; export { useReadQuery } from "./useReadQuery.js"; export { skipToken } from "./constants.js"; diff --git a/src/react/hooks/internal/index.ts b/src/react/hooks/internal/index.ts index d1c90c41f4a..2a45719986b 100644 --- a/src/react/hooks/internal/index.ts +++ b/src/react/hooks/internal/index.ts @@ -1,4 +1,5 @@ // These hooks are used internally and are not exported publicly by the library export { useDeepMemo } from "./useDeepMemo.js"; export { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js"; +export { useRenderGuard } from "./useRenderGuard.js"; export { __use } from "./__use.js"; diff --git a/src/react/hooks/internal/useRenderGuard.ts b/src/react/hooks/internal/useRenderGuard.ts new file mode 100644 index 00000000000..98bb21a8ef1 --- /dev/null +++ b/src/react/hooks/internal/useRenderGuard.ts @@ -0,0 +1,22 @@ +import * as React from "rehackt"; + +function getRenderDispatcher() { + return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED + ?.ReactCurrentDispatcher?.current; +} + +let RenderDispatcher: unknown = null; + +/* +Relay does this too, so we hope this is safe. +https://github.com/facebook/relay/blob/8651fbca19adbfbb79af7a3bc40834d105fd7747/packages/react-relay/relay-hooks/loadQuery.js#L90-L98 +*/ +export function useRenderGuard() { + RenderDispatcher = getRenderDispatcher(); + + return React.useCallback(() => { + return ( + RenderDispatcher !== null && RenderDispatcher === getRenderDispatcher() + ); + }, []); +} diff --git a/src/react/hooks/useLoadableQuery.ts b/src/react/hooks/useLoadableQuery.ts new file mode 100644 index 00000000000..2ec551bf26e --- /dev/null +++ b/src/react/hooks/useLoadableQuery.ts @@ -0,0 +1,230 @@ +import * as React from "rehackt"; +import type { + DocumentNode, + FetchMoreQueryOptions, + OperationVariables, + TypedDocumentNode, + WatchQueryOptions, +} from "../../core/index.js"; +import { useApolloClient } from "./useApolloClient.js"; +import { wrapQueryRef } from "../cache/QueryReference.js"; +import type { + QueryReference, + InternalQueryReference, +} from "../cache/QueryReference.js"; +import type { LoadableQueryHookOptions } from "../types/types.js"; +import { __use, useRenderGuard } from "./internal/index.js"; +import { getSuspenseCache } from "../cache/index.js"; +import { useWatchQueryOptions } from "./useSuspenseQuery.js"; +import type { FetchMoreFunction, RefetchFunction } from "./useSuspenseQuery.js"; +import { canonicalStringify } from "../../cache/index.js"; +import type { + DeepPartial, + OnlyRequiredProperties, +} from "../../utilities/index.js"; +import type { CacheKey } from "../cache/types.js"; +import { invariant } from "../../utilities/globals/index.js"; + +export type LoadQueryFunction = ( + // Use variadic args to handle cases where TVariables is type `never`, in + // which case we don't want to allow a variables argument. In other + // words, we don't want to allow variables to be passed as an argument to this + // function if the query does not expect variables in the document. + ...args: [TVariables] extends [never] + ? [] + : {} extends OnlyRequiredProperties + ? [variables?: TVariables] + : [variables: TVariables] +) => void; + +type ResetFunction = () => void; + +export type UseLoadableQueryResult< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +> = [ + LoadQueryFunction, + QueryReference | null, + { + fetchMore: FetchMoreFunction; + refetch: RefetchFunction; + reset: ResetFunction; + }, +]; + +export function useLoadableQuery< + TData, + TVariables extends OperationVariables, + TOptions extends LoadableQueryHookOptions, +>( + query: DocumentNode | TypedDocumentNode, + options?: LoadableQueryHookOptions & TOptions +): UseLoadableQueryResult< + TOptions["errorPolicy"] extends "ignore" | "all" + ? TOptions["returnPartialData"] extends true + ? DeepPartial | undefined + : TData | undefined + : TOptions["returnPartialData"] extends true + ? DeepPartial + : TData, + TVariables +>; + +export function useLoadableQuery< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: LoadableQueryHookOptions & { + returnPartialData: true; + errorPolicy: "ignore" | "all"; + } +): UseLoadableQueryResult | undefined, TVariables>; + +export function useLoadableQuery< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: LoadableQueryHookOptions & { + errorPolicy: "ignore" | "all"; + } +): UseLoadableQueryResult; + +export function useLoadableQuery< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: LoadableQueryHookOptions & { + returnPartialData: true; + } +): UseLoadableQueryResult, TVariables>; + +export function useLoadableQuery< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options?: LoadableQueryHookOptions +): UseLoadableQueryResult; + +export function useLoadableQuery< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: LoadableQueryHookOptions = Object.create(null) +): UseLoadableQueryResult { + const client = useApolloClient(options.client); + const suspenseCache = getSuspenseCache(client); + const watchQueryOptions = useWatchQueryOptions({ client, query, options }); + const { queryKey = [] } = options; + + const [queryRef, setQueryRef] = + React.useState | null>(null); + + const [promiseCache, setPromiseCache] = React.useState(() => + queryRef ? new Map([[queryRef.key, queryRef.promise]]) : new Map() + ); + + if (queryRef?.didChangeOptions(watchQueryOptions)) { + const promise = queryRef.applyOptions(watchQueryOptions); + promiseCache.set(queryRef.key, promise); + } + + if (queryRef) { + queryRef.promiseCache = promiseCache; + } + + const calledDuringRender = useRenderGuard(); + + React.useEffect(() => queryRef?.retain(), [queryRef]); + + const fetchMore: FetchMoreFunction = React.useCallback( + (options) => { + if (!queryRef) { + throw new Error( + "The query has not been loaded. Please load the query." + ); + } + + const promise = queryRef.fetchMore( + options as FetchMoreQueryOptions + ); + + setPromiseCache((promiseCache) => + new Map(promiseCache).set(queryRef.key, queryRef.promise) + ); + + return promise; + }, + [queryRef] + ); + + const refetch: RefetchFunction = React.useCallback( + (options) => { + if (!queryRef) { + throw new Error( + "The query has not been loaded. Please load the query." + ); + } + + const promise = queryRef.refetch(options); + + setPromiseCache((promiseCache) => + new Map(promiseCache).set(queryRef.key, queryRef.promise) + ); + + return promise; + }, + [queryRef] + ); + + const loadQuery: LoadQueryFunction = React.useCallback( + (...args) => { + invariant( + !calledDuringRender(), + "useLoadableQuery: 'loadQuery' should not be called during render. To start a query during render, use the 'useBackgroundQuery' hook." + ); + + const [variables] = args; + + const cacheKey: CacheKey = [ + query, + canonicalStringify(variables), + ...([] as any[]).concat(queryKey), + ]; + + const queryRef = suspenseCache.getQueryRef(cacheKey, () => + client.watchQuery({ + ...watchQueryOptions, + variables, + } as WatchQueryOptions) + ); + + promiseCache.set(queryRef.key, queryRef.promise); + setQueryRef(queryRef); + }, + [ + query, + queryKey, + suspenseCache, + watchQueryOptions, + promiseCache, + calledDuringRender, + ] + ); + + const reset: ResetFunction = React.useCallback(() => { + setQueryRef(null); + }, [queryRef]); + + return React.useMemo(() => { + return [ + loadQuery, + queryRef && wrapQueryRef(queryRef), + { fetchMore, refetch, reset }, + ]; + }, [queryRef, loadQuery, fetchMore, refetch, reset]); +} diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 70df3b03458..f6f7af613aa 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -20,6 +20,8 @@ import type { InternalRefetchQueriesInclude, WatchQueryOptions, WatchQueryFetchPolicy, + ErrorPolicy, + RefetchWritePolicy, } from "../../core/index.js"; /* QueryReference type */ @@ -188,6 +190,67 @@ export interface BackgroundQueryHookOptions< skip?: boolean; } +export type LoadableQueryHookFetchPolicy = Extract< + WatchQueryFetchPolicy, + "cache-first" | "network-only" | "no-cache" | "cache-and-network" +>; + +export interface LoadableQueryHookOptions { + /** + * Whether to canonize cache results before returning them. Canonization + * takes some extra time, but it speeds up future deep equality comparisons. + * Defaults to false. + */ + canonizeResults?: boolean; + /** + * The instance of {@link ApolloClient} to use to execute the query. + * + * By default, the instance that's passed down via context is used, but you + * can provide a different instance here. + */ + client?: ApolloClient; + /** + * Context to be passed to link execution chain + */ + context?: DefaultContext; + /** + * Specifies the {@link ErrorPolicy} to be used for this query + */ + errorPolicy?: ErrorPolicy; + /** + * + * Specifies how the query interacts with the Apollo Client cache during + * execution (for example, whether it checks the cache for results before + * sending a request to the server). + * + * For details, see {@link https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy | Setting a fetch policy}. + * + * The default value is `cache-first`. + */ + fetchPolicy?: LoadableQueryHookFetchPolicy; + /** + * A unique identifier for the query. Each item in the array must be a stable + * identifier to prevent infinite fetches. + * + * This is useful when using the same query and variables combination in more + * than one component, otherwise the components may clobber each other. This + * can also be used to force the query to re-evaluate fresh. + */ + queryKey?: string | number | any[]; + /** + * Specifies whether a {@link NetworkStatus.refetch} operation should merge + * incoming field data with existing data, or overwrite the existing data. + * Overwriting is probably preferable, but merging is currently the default + * behavior, for backwards compatibility with Apollo Client 3.x. + */ + refetchWritePolicy?: RefetchWritePolicy; + /** + * Allow returning incomplete data from the cache when a larger query cannot + * be fully satisfied by the cache, instead of returning nothing. + */ + returnPartialData?: boolean; +} + /** * @deprecated TODO Delete this unused interface. */ diff --git a/src/testing/internal/disposables/disableActWarnings.ts b/src/testing/internal/disposables/disableActWarnings.ts new file mode 100644 index 00000000000..c5254c8dc1d --- /dev/null +++ b/src/testing/internal/disposables/disableActWarnings.ts @@ -0,0 +1,15 @@ +import { withCleanup } from "./withCleanup.js"; + +/** + * Temporarily disable act warnings. + * + * https://github.com/reactwg/react-18/discussions/102 + */ +export function disableActWarnings() { + const prev = { prevActEnv: (globalThis as any).IS_REACT_ACT_ENVIRONMENT }; + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = false; + + return withCleanup(prev, ({ prevActEnv }) => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = prevActEnv; + }); +} diff --git a/src/testing/internal/disposables/index.ts b/src/testing/internal/disposables/index.ts index 6d232565db4..9895d129589 100644 --- a/src/testing/internal/disposables/index.ts +++ b/src/testing/internal/disposables/index.ts @@ -1,2 +1,3 @@ +export { disableActWarnings } from "./disableActWarnings.js"; export { spyOnConsole } from "./spyOnConsole.js"; export { withCleanup } from "./withCleanup.js"; diff --git a/src/testing/internal/profile/Render.tsx b/src/testing/internal/profile/Render.tsx index 24b4737c2c0..c077c63fac3 100644 --- a/src/testing/internal/profile/Render.tsx +++ b/src/testing/internal/profile/Render.tsx @@ -62,6 +62,8 @@ export interface Render extends BaseRender { * ``` */ withinDOM: () => SyncScreen; + + renderedComponents: Array; } /** @internal */ @@ -77,7 +79,8 @@ export class RenderInstance implements Render { constructor( baseRender: BaseRender, public snapshot: Snapshot, - private stringifiedDOM: string | undefined + private stringifiedDOM: string | undefined, + public renderedComponents: Array ) { this.id = baseRender.id; this.phase = baseRender.phase; diff --git a/src/testing/internal/profile/context.tsx b/src/testing/internal/profile/context.tsx new file mode 100644 index 00000000000..a8488e73a6c --- /dev/null +++ b/src/testing/internal/profile/context.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +export interface ProfilerContextValue { + renderedComponents: Array; +} + +const ProfilerContext = React.createContext( + undefined +); + +export function ProfilerContextProvider({ + children, + value, +}: { + children: React.ReactNode; + value: ProfilerContextValue; +}) { + const parentContext = useProfilerContext(); + + if (parentContext) { + throw new Error("Profilers should not be nested in the same tree"); + } + + return ( + + {children} + + ); +} + +export function useProfilerContext() { + return React.useContext(ProfilerContext); +} diff --git a/src/testing/internal/profile/index.ts b/src/testing/internal/profile/index.ts index 01bb526c52c..3d9ddd55559 100644 --- a/src/testing/internal/profile/index.ts +++ b/src/testing/internal/profile/index.ts @@ -1,8 +1,15 @@ export type { NextRenderOptions, + Profiler, ProfiledComponent, ProfiledHook, } from "./profile.js"; -export { profile, profileHook, WaitForRenderTimeoutError } from "./profile.js"; +export { + createProfiler, + profile, + profileHook, + useTrackRenders, + WaitForRenderTimeoutError, +} from "./profile.js"; export type { SyncScreen } from "./Render.js"; diff --git a/src/testing/internal/profile/profile.tsx b/src/testing/internal/profile/profile.tsx index f4abb64af0e..4b2717dc21d 100644 --- a/src/testing/internal/profile/profile.tsx +++ b/src/testing/internal/profile/profile.tsx @@ -8,6 +8,9 @@ global.TextDecoder ??= TextDecoder; import type { Render, BaseRender } from "./Render.js"; import { RenderInstance } from "./Render.js"; import { applyStackTrace, captureStackTrace } from "./traces.js"; +import type { ProfilerContextValue } from "./context.js"; +import { ProfilerContextProvider, useProfilerContext } from "./context.js"; +import { disableActWarnings } from "../disposables/index.js"; type ValidSnapshot = void | (object & { /* not a function */ call?: never }); @@ -20,20 +23,37 @@ export interface NextRenderOptions { } /** @internal */ -export interface ProfiledComponent - extends React.FC, - ProfiledComponentFields, - ProfiledComponenOnlyFields {} +interface ProfilerProps { + children: React.ReactNode; +} -interface UpdateSnapshot { +/** @internal */ +export interface Profiler + extends React.FC, + ProfiledComponentFields, + ProfiledComponentOnlyFields {} + +interface ReplaceSnapshot { (newSnapshot: Snapshot): void; (updateSnapshot: (lastSnapshot: Readonly) => Snapshot): void; } -interface ProfiledComponenOnlyFields { - updateSnapshot: UpdateSnapshot; +interface MergeSnapshot { + (partialSnapshot: Partial): void; + ( + updatePartialSnapshot: ( + lastSnapshot: Readonly + ) => Partial + ): void; } -interface ProfiledComponentFields { + +interface ProfiledComponentOnlyFields { + // Allows for partial updating of the snapshot by shallow merging the results + mergeSnapshot: MergeSnapshot; + // Performs a full replacement of the snapshot + replaceSnapshot: ReplaceSnapshot; +} +interface ProfiledComponentFields { /** * An array of all renders that have happened so far. * Errors thrown during component render will be captured here, too. @@ -54,21 +74,14 @@ interface ProfiledComponentFields { */ takeRender(options?: NextRenderOptions): Promise>; /** - * Returns the current render count. + * Returns the total number of renders. */ - currentRenderCount(): number; + totalRenderCount(): number; /** * Returns the current render. * @throws {Error} if no render has happened yet */ getCurrentRender(): Render; - /** - * Iterates the renders until the render count is reached. - */ - takeUntilRenderCount( - count: number, - optionsPerRender?: NextRenderOptions - ): Promise; /** * Waits for the next render to happen. * Does not advance the render iterator. @@ -76,32 +89,73 @@ interface ProfiledComponentFields { waitForNextRender(options?: NextRenderOptions): Promise>; } +export interface ProfiledComponent + extends React.FC, + ProfiledComponentFields, + ProfiledComponentOnlyFields {} + /** @internal */ -export function profile< - Snapshot extends ValidSnapshot = void, - Props = Record, ->({ +export function profile({ Component, + ...options +}: { + onRender?: ( + info: BaseRender & { + snapshot: Snapshot; + replaceSnapshot: ReplaceSnapshot; + mergeSnapshot: MergeSnapshot; + } + ) => void; + Component: React.ComponentType; + snapshotDOM?: boolean; + initialSnapshot?: Snapshot; +}): ProfiledComponent { + const Profiler = createProfiler(options); + + return Object.assign( + function ProfiledComponent(props: Props) { + return ( + + + + ); + }, + { + mergeSnapshot: Profiler.mergeSnapshot, + replaceSnapshot: Profiler.replaceSnapshot, + getCurrentRender: Profiler.getCurrentRender, + peekRender: Profiler.peekRender, + takeRender: Profiler.takeRender, + totalRenderCount: Profiler.totalRenderCount, + waitForNextRender: Profiler.waitForNextRender, + get renders() { + return Profiler.renders; + }, + } + ); +} + +/** @internal */ +export function createProfiler({ onRender, snapshotDOM = false, initialSnapshot, }: { - Component: React.ComponentType; onRender?: ( info: BaseRender & { snapshot: Snapshot; - updateSnapshot: UpdateSnapshot; + replaceSnapshot: ReplaceSnapshot; + mergeSnapshot: MergeSnapshot; } ) => void; snapshotDOM?: boolean; initialSnapshot?: Snapshot; -}) { - let currentRender: Render | undefined; +} = {}) { let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; let rejectNextRender: ((error: unknown) => void) | undefined; const snapshotRef = { current: initialSnapshot }; - const updateSnapshot: UpdateSnapshot = (snap) => { + const replaceSnapshot: ReplaceSnapshot = (snap) => { if (typeof snap === "function") { if (!initialSnapshot) { throw new Error( @@ -118,6 +172,20 @@ export function profile< snapshotRef.current = snap; } }; + + const mergeSnapshot: MergeSnapshot = (partialSnapshot) => { + replaceSnapshot((snapshot) => ({ + ...snapshot, + ...(typeof partialSnapshot === "function" + ? partialSnapshot(snapshot) + : partialSnapshot), + })); + }; + + const profilerContext: ProfilerContextValue = { + renderedComponents: [], + }; + const profilerOnRender: React.ProfilerOnRenderCallback = ( id, phase, @@ -133,7 +201,7 @@ export function profile< baseDuration, startTime, commitTime, - count: Profiled.renders.length + 1, + count: Profiler.renders.length + 1, }; try { /* @@ -145,7 +213,8 @@ export function profile< */ onRender?.({ ...baseRender, - updateSnapshot, + replaceSnapshot, + mergeSnapshot, snapshot: snapshotRef.current!, }); @@ -153,15 +222,19 @@ export function profile< const domSnapshot = snapshotDOM ? window.document.body.innerHTML : undefined; - const render = new RenderInstance(baseRender, snapshot, domSnapshot); - // eslint-disable-next-line testing-library/render-result-naming-convention - currentRender = render; - Profiled.renders.push(render); + const render = new RenderInstance( + baseRender, + snapshot, + domSnapshot, + profilerContext.renderedComponents + ); + profilerContext.renderedComponents = []; + Profiler.renders.push(render); resolveNextRender?.(render); } catch (error) { - Profiled.renders.push({ + Profiler.renders.push({ phase: "snapshotError", - count: Profiled.renders.length, + count: Profiler.renders.length, error, }); rejectNextRender?.(error); @@ -171,42 +244,54 @@ export function profile< }; let iteratorPosition = 0; - const Profiled: ProfiledComponent = Object.assign( - (props: Props) => ( - - - - ), + const Profiler: Profiler = Object.assign( + ({ children }: ProfilerProps) => { + return ( + + + {children} + + + ); + }, { - updateSnapshot, - } satisfies ProfiledComponenOnlyFields, + replaceSnapshot, + mergeSnapshot, + } satisfies ProfiledComponentOnlyFields, { renders: new Array< | Render | { phase: "snapshotError"; count: number; error: unknown } >(), - currentRenderCount() { - return Profiled.renders.length; + totalRenderCount() { + return Profiler.renders.length; }, async peekRender(options: NextRenderOptions = {}) { - if (iteratorPosition < Profiled.renders.length) { - const render = Profiled.renders[iteratorPosition]; + if (iteratorPosition < Profiler.renders.length) { + const render = Profiler.renders[iteratorPosition]; + if (render.phase === "snapshotError") { throw render.error; } + return render; } - const render = Profiled.waitForNextRender({ - [_stackTrace]: captureStackTrace(Profiled.peekRender), + return Profiler.waitForNextRender({ + [_stackTrace]: captureStackTrace(Profiler.peekRender), ...options, }); - return render; }, async takeRender(options: NextRenderOptions = {}) { + // In many cases we do not control the resolution of the suspended + // promise which results in noisy tests when the profiler due to + // repeated act warnings. + using _disabledActWarnings = disableActWarnings(); + let error: unknown = undefined; + try { - return await Profiled.peekRender({ - [_stackTrace]: captureStackTrace(Profiled.takeRender), + return await Profiler.peekRender({ + [_stackTrace]: captureStackTrace(Profiler.takeRender), ...options, }); } catch (e) { @@ -219,24 +304,31 @@ export function profile< } }, getCurrentRender() { - if (!currentRender) { - throw new Error("Has not been rendered yet!"); + // The "current" render should point at the same render that the most + // recent `takeRender` call returned, so we need to get the "previous" + // iterator position, otherwise `takeRender` advances the iterator + // to the next render. This means we need to call `takeRender` at least + // once before we can get a current render. + const currentPosition = iteratorPosition - 1; + + if (currentPosition < 0) { + throw new Error( + "No current render available. You need to call `takeRender` before you can get the current render." + ); } - return currentRender; - }, - async takeUntilRenderCount( - count: number, - optionsPerRender?: NextRenderOptions - ) { - while (Profiled.renders.length < count) { - await Profiled.takeRender(optionsPerRender); + + const render = Profiler.renders[currentPosition]; + + if (render.phase === "snapshotError") { + throw render.error; } + return render; }, waitForNextRender({ timeout = 1000, // capture the stack trace here so its stack trace is as close to the calling code as possible [_stackTrace]: stackTrace = captureStackTrace( - Profiled.waitForNextRender + Profiler.waitForNextRender ), }: NextRenderOptions = {}) { if (!nextRender) { @@ -258,9 +350,9 @@ export function profile< } return nextRender; }, - } satisfies ProfiledComponentFields + } satisfies ProfiledComponentFields ); - return Profiled; + return Profiler; } /** @internal */ @@ -282,60 +374,86 @@ type ResultReplaceRenderWithSnapshot = T extends ( ? (...args: Args) => Promise : T; -type ProfiledHookFields = ProfiledComponentFields< - Props, - ReturnValue -> extends infer PC - ? { - [K in keyof PC as StringReplaceRenderWithSnapshot< - K & string - >]: ResultReplaceRenderWithSnapshot; - } - : never; +type ProfiledHookFields = + ProfiledComponentFields extends infer PC + ? { + [K in keyof PC as StringReplaceRenderWithSnapshot< + K & string + >]: ResultReplaceRenderWithSnapshot; + } + : never; /** @internal */ export interface ProfiledHook extends React.FC, - ProfiledHookFields { - ProfiledComponent: ProfiledComponent; + ProfiledHookFields { + Profiler: Profiler; } /** @internal */ export function profileHook( renderCallback: (props: Props) => ReturnValue ): ProfiledHook { - let returnValue: ReturnValue; - const Component = (props: Props) => { - ProfiledComponent.updateSnapshot(renderCallback(props)); + const Profiler = createProfiler(); + + const ProfiledHook = (props: Props) => { + Profiler.replaceSnapshot(renderCallback(props)); return null; }; - const ProfiledComponent = profile({ - Component, - onRender: () => returnValue, - }); + return Object.assign( - function ProfiledHook(props: Props) { - return ; + function App(props: Props) { + return ( + + + + ); }, { - ProfiledComponent, + Profiler, }, { - renders: ProfiledComponent.renders, - currentSnapshotCount: ProfiledComponent.currentRenderCount, + renders: Profiler.renders, + totalSnapshotCount: Profiler.totalRenderCount, async peekSnapshot(options) { - return (await ProfiledComponent.peekRender(options)).snapshot; + return (await Profiler.peekRender(options)).snapshot; }, async takeSnapshot(options) { - return (await ProfiledComponent.takeRender(options)).snapshot; + return (await Profiler.takeRender(options)).snapshot; }, getCurrentSnapshot() { - return ProfiledComponent.getCurrentRender().snapshot; + return Profiler.getCurrentRender().snapshot; }, - takeUntilSnapshotCount: ProfiledComponent.takeUntilRenderCount, async waitForNextSnapshot(options) { - return (await ProfiledComponent.waitForNextRender(options)).snapshot; + return (await Profiler.waitForNextRender(options)).snapshot; }, - } satisfies ProfiledHookFields + } satisfies ProfiledHookFields ); } + +function resolveHookOwner(): React.ComponentType | undefined { + return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED + ?.ReactCurrentOwner?.current?.elementType; +} + +export function useTrackRenders({ name }: { name?: string } = {}) { + const component = name || resolveHookOwner(); + + if (!component) { + throw new Error( + "useTrackRender: Unable to determine component. Please ensure the hook is called inside a rendered component or provide a `name` option." + ); + } + + const ctx = useProfilerContext(); + + if (!ctx) { + throw new Error( + "useTrackComponentRender: A Profiler must be created and rendered to track component renders" + ); + } + + React.useLayoutEffect(() => { + ctx.renderedComponents.unshift(component); + }); +} diff --git a/src/testing/matchers/ProfiledComponent.ts b/src/testing/matchers/ProfiledComponent.ts index 469cfe00995..435c24a29a6 100644 --- a/src/testing/matchers/ProfiledComponent.ts +++ b/src/testing/matchers/ProfiledComponent.ts @@ -2,22 +2,22 @@ import type { MatcherFunction } from "expect"; import { WaitForRenderTimeoutError } from "../internal/index.js"; import type { NextRenderOptions, + Profiler, ProfiledComponent, ProfiledHook, } from "../internal/index.js"; + export const toRerender: MatcherFunction<[options?: NextRenderOptions]> = async function (actual, options) { - const _profiled = actual as + const _profiler = actual as + | Profiler | ProfiledComponent | ProfiledHook; - const profiled = - "ProfiledComponent" in _profiled - ? _profiled.ProfiledComponent - : _profiled; - const hint = this.utils.matcherHint("toRerender"); + const profiler = "Profiler" in _profiler ? _profiler.Profiler : _profiler; + const hint = this.utils.matcherHint("toRerender", "ProfiledComponent", ""); let pass = true; try { - await profiled.peekRender({ timeout: 100, ...options }); + await profiler.peekRender({ timeout: 100, ...options }); } catch (e) { if (e instanceof WaitForRenderTimeoutError) { pass = false; @@ -25,12 +25,13 @@ export const toRerender: MatcherFunction<[options?: NextRenderOptions]> = throw e; } } + return { pass, message() { return ( hint + - ` Expected component to${pass ? " not" : ""} rerender, ` + + `\n\nExpected component to${pass ? " not" : ""} rerender, ` + `but it did${pass ? "" : " not"}.` ); }, @@ -43,28 +44,28 @@ const failed = {}; export const toRenderExactlyTimes: MatcherFunction< [times: number, options?: NextRenderOptions] > = async function (actual, times, optionsPerRender) { - const _profiled = actual as + const _profiler = actual as + | Profiler | ProfiledComponent | ProfiledHook; - const profiled = - "ProfiledComponent" in _profiled ? _profiled.ProfiledComponent : _profiled; + const profiler = "Profiler" in _profiler ? _profiler.Profiler : _profiler; const options = { timeout: 100, ...optionsPerRender }; const hint = this.utils.matcherHint("toRenderExactlyTimes"); let pass = true; try { - if (profiled.currentRenderCount() > times) { + if (profiler.totalRenderCount() > times) { throw failed; } try { - while (profiled.currentRenderCount() < times) { - await profiled.waitForNextRender(options); + while (profiler.totalRenderCount() < times) { + await profiler.waitForNextRender(options); } } catch (e) { // timeouts here should just fail the test, rethrow other errors throw e instanceof WaitForRenderTimeoutError ? failed : e; } try { - await profiled.waitForNextRender(options); + await profiler.waitForNextRender(options); } catch (e) { // we are expecting a timeout here, so swallow that error, rethrow others if (!(e instanceof WaitForRenderTimeoutError)) { @@ -84,7 +85,7 @@ export const toRenderExactlyTimes: MatcherFunction< return ( hint + ` Expected component to${pass ? " not" : ""} render exactly ${times}.` + - ` It rendered ${profiled.currentRenderCount()} times.` + ` It rendered ${profiler.totalRenderCount()} times.` ); }, }; diff --git a/src/testing/matchers/index.d.ts b/src/testing/matchers/index.d.ts index 715f7d3dbdf..b09a823dc25 100644 --- a/src/testing/matchers/index.d.ts +++ b/src/testing/matchers/index.d.ts @@ -5,6 +5,7 @@ import type { } from "../../core/index.js"; import { NextRenderOptions, + Profiler, ProfiledComponent, ProfiledHook, } from "../internal/index.js"; @@ -29,11 +30,15 @@ interface ApolloCustomMatchers { ) => R : { error: "matcher needs to be called on an ApolloClient instance" }; - toRerender: T extends ProfiledComponent | ProfiledHook + toRerender: T extends + | Profiler + | ProfiledComponent + | ProfiledHook ? (options?: NextRenderOptions) => Promise : { error: "matcher needs to be called on a ProfiledComponent instance" }; toRenderExactlyTimes: T extends + | Profiler | ProfiledComponent | ProfiledHook ? (count: number, options?: NextRenderOptions) => Promise diff --git a/src/utilities/graphql/print.ts b/src/utilities/graphql/print.ts index d536ff8bb77..3ba1134c968 100644 --- a/src/utilities/graphql/print.ts +++ b/src/utilities/graphql/print.ts @@ -8,7 +8,8 @@ export const print = Object.assign( let result = printCache.get(ast); if (!result) { - printCache.set(ast, (result = origPrint(ast))); + result = origPrint(ast); + printCache.set(ast, result); } return result; }, diff --git a/src/utilities/index.ts b/src/utilities/index.ts index da8affb4b5a..ec05d2aa043 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -129,3 +129,4 @@ export { stripTypename } from "./common/stripTypename.js"; export * from "./types/IsStrictlyAny.js"; export type { DeepOmit } from "./types/DeepOmit.js"; export type { DeepPartial } from "./types/DeepPartial.js"; +export type { OnlyRequiredProperties } from "./types/OnlyRequiredProperties.js"; diff --git a/src/utilities/types/OnlyRequiredProperties.ts b/src/utilities/types/OnlyRequiredProperties.ts new file mode 100644 index 00000000000..5264a0fca69 --- /dev/null +++ b/src/utilities/types/OnlyRequiredProperties.ts @@ -0,0 +1,6 @@ +/** + * Returns a new type that only contains the required properties from `T` + */ +export type OnlyRequiredProperties = { + [K in keyof T as {} extends Pick ? never : K]: T[K]; +};