diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 53a5e866698..017e6932435 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -1726,8 +1726,8 @@ export class ObservableQuery>): void; startPolling(pollInterval: number): void; stopPolling(): void; - subscribeToMore(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -2317,12 +2317,33 @@ class Stump extends Layer { } // @public (undocumented) -export type SubscribeToMoreOptions = { +export interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} + +// @public (undocumented) +export interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +export type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -2421,18 +2442,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +export interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} // @public (undocumented) -export interface UpdateQueryOptions { - // (undocumented) +export type UpdateQueryOptions = { variables?: TVariables; -} +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) export interface UriFunction { @@ -2508,11 +2533,10 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/policies.ts:162:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:162:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (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 // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index aa55cb6c431..380070343f9 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -373,6 +373,7 @@ type BackgroundQueryHookOptionsNoInfer = ApolloCache> extends MutationSharedOptions { client?: ApolloClient; + // @deprecated ignoreResults?: boolean; notifyOnNetworkStatusChange?: boolean; onCompleted?: (data: MaybeMasked, clientOptions?: BaseMutationOptions) => void; @@ -885,6 +886,11 @@ TData }; } : never : never; +// Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type From = StoreObject | Reference | FragmentType> | string | null; + // @internal const getApolloCacheMemoryInternals: (() => { cache: { @@ -1042,7 +1048,9 @@ export interface LazyQueryHookExecOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; } @@ -1428,8 +1436,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1446,8 +1455,9 @@ export interface ObservableQueryFields>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1537,8 +1547,6 @@ export interface PreloadQueryFunction { (query: DocumentNode | TypedDocumentNode, ...[options]: PreloadQueryOptionsArg>): PreloadedQueryRef; } -// Warning: (ae-forgotten-export) The symbol "VariablesOption" needs to be exported by the entry point index.d.ts -// // @public (undocumented) export type PreloadQueryOptions = { canonizeResults?: boolean; @@ -1588,7 +1596,9 @@ export interface QueryDataOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -2087,15 +2097,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreFunction = ObservableQueryFields["subscribeToMore"]; +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: Context; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: Context; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -2217,12 +2247,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -2316,8 +2356,6 @@ export function useFragment(options: Us // @public (undocumented) export interface UseFragmentOptions extends Omit, NoInfer_2>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { client?: ApolloClient; - // Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts - // // (undocumented) from: StoreObject | Reference | FragmentType> | string | null; // (undocumented) @@ -2412,6 +2450,40 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: NonNullable>; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: From; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +type UseSuspenseFragmentOptions = { + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; + from: From; + optimistic?: boolean; + client?: ApolloClient; +} & VariablesOption>; + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; @@ -2472,11 +2544,11 @@ export interface UseSuspenseQueryResult = [ +export type VariablesOption = [ TVariables ] extends [never] ? { variables?: Record; -} : {} extends OnlyRequiredProperties ? { +} : Record extends OnlyRequiredProperties ? { variables?: TVariables; } : { variables: TVariables; @@ -2519,17 +2591,17 @@ interface WatchQueryOptions = ApolloCache> extends MutationSharedOptions { // Warning: (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts client?: ApolloClient; + // @deprecated ignoreResults?: boolean; notifyOnNetworkStatusChange?: boolean; onCompleted?: (data: MaybeMasked, clientOptions?: BaseMutationOptions) => void; @@ -1306,8 +1307,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1324,8 +1326,9 @@ interface ObservableQueryFields { reobserve: (newOptions?: Partial>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1416,7 +1419,9 @@ export interface QueryComponentOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -1838,12 +1843,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} + +// @public (undocumented) +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public @deprecated (undocumented) @@ -1950,12 +1978,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -2001,13 +2039,13 @@ interface WatchQueryOptions(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1278,8 +1279,9 @@ interface ObservableQueryFields { reobserve: (newOptions?: Partial>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1344,7 +1346,9 @@ interface QueryDataOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -1791,12 +1795,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} + +// @public (undocumented) +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1870,12 +1897,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -1921,13 +1958,13 @@ interface WatchQueryOptions = ApolloCache> extends MutationSharedOptions { // Warning: (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts client?: ApolloClient; + // @deprecated ignoreResults?: boolean; notifyOnNetworkStatusChange?: boolean; onCompleted?: (data: MaybeMasked, clientOptions?: BaseMutationOptions) => void; @@ -1299,8 +1300,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1386,10 +1388,8 @@ export interface QueryControls void; // (undocumented) subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - // Warning: (ae-forgotten-export) The symbol "UpdateQueryOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) - updateQuery: (mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any) => void; + updateQuery: (mapFn: UpdateQueryMapFn) => void; // (undocumented) variables: TGraphQLVariables; } @@ -1795,12 +1795,29 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1874,18 +1891,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} // @public (undocumented) -interface UpdateQueryOptions { - // (undocumented) +type UpdateQueryOptions = { variables?: TVariables; -} +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -1948,13 +1969,13 @@ export function withSubscription = ApolloCache> extends MutationSharedOptions { // Warning: (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts client?: ApolloClient; + // @deprecated ignoreResults?: boolean; notifyOnNetworkStatusChange?: boolean; onCompleted?: (data: MaybeMasked, clientOptions?: BaseMutationOptions) => void; @@ -840,6 +841,11 @@ TData }; } : never : never; +// Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type From = StoreObject | Reference | FragmentType> | string | null; + // @internal const getApolloCacheMemoryInternals: (() => { cache: { @@ -991,7 +997,9 @@ interface LazyQueryHookExecOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; } @@ -1372,8 +1380,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1390,8 +1399,9 @@ interface ObservableQueryFields { reobserve: (newOptions?: Partial>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1461,7 +1471,9 @@ const QUERY_REF_BRAND: unique symbol; interface QueryFunctionOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -1923,15 +1935,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreFunction = ObservableQueryFields["subscribeToMore"]; +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // Warning: (ae-forgotten-export) The symbol "BaseSubscriptionOptions" needs to be exported by the entry point index.d.ts @@ -2040,12 +2072,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -2140,8 +2182,6 @@ export function useFragment(options: Us // @public (undocumented) export interface UseFragmentOptions extends Omit, NoInfer_2>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { client?: ApolloClient; - // Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts - // // (undocumented) from: StoreObject | Reference | FragmentType> | string | null; // (undocumented) @@ -2245,6 +2285,42 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: NonNullable>; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: From; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// Warning: (ae-forgotten-export) The symbol "VariablesOption" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type UseSuspenseFragmentOptions = { + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; + from: From; + optimistic?: boolean; + client?: ApolloClient; +} & VariablesOption>; + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // Warning: (ae-forgotten-export) The symbol "SuspenseQueryHookOptions" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -2306,6 +2382,17 @@ export interface UseSuspenseQueryResult; } +// @public (undocumented) +type VariablesOption = [ +TVariables +] extends [never] ? { + variables?: Record; +} : Record extends OnlyRequiredProperties ? { + variables?: TVariables; +} : { + variables: TVariables; +}; + // @public interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; @@ -2343,17 +2430,17 @@ interface WatchQueryOptions; } +// @public (undocumented) +type FragmentCacheKey = [ +cacheId: string, +fragment: DocumentNode, +stringifiedVariables: string +]; + +// @public (undocumented) +interface FragmentKey { + // (undocumented) + __fragmentKey?: string; +} + // @public interface FragmentMap { // (undocumented) @@ -816,6 +829,43 @@ interface FragmentMap { // @public (undocumented) type FragmentMatcher = (rootValue: any, typeCondition: string, context: any) => boolean; +// @public (undocumented) +class FragmentReference> { + // Warning: (ae-forgotten-export) The symbol "FragmentReferenceOptions" needs to be exported by the entry point index.d.ts + constructor(client: ApolloClient, watchFragmentOptions: WatchFragmentOptions & { + from: string; + }, options: FragmentReferenceOptions); + // Warning: (ae-forgotten-export) The symbol "FragmentKey" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly key: FragmentKey; + // Warning: (ae-forgotten-export) The symbol "Listener_2" needs to be exported by the entry point index.d.ts + // + // (undocumented) + listen(listener: Listener_2>): () => void; + // (undocumented) + readonly observable: Observable>; + // Warning: (ae-forgotten-export) The symbol "FragmentRefPromise" needs to be exported by the entry point index.d.ts + // + // (undocumented) + promise: FragmentRefPromise>; + // (undocumented) + retain(): () => void; +} + +// @public (undocumented) +interface FragmentReferenceOptions { + // (undocumented) + autoDisposeTimeoutMs?: number; + // (undocumented) + onDispose?: () => void; +} + +// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type FragmentRefPromise = PromiseWithState; + // @public (undocumented) type FragmentType = [ TData @@ -827,6 +877,11 @@ TData }; } : never : never; +// Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type From = StoreObject | Reference | FragmentType> | string | null; + // @public (undocumented) interface FulfilledPromise extends Promise { // (undocumented) @@ -1036,6 +1091,9 @@ type IsStrictlyAny = UnionToIntersection> extends never ? true // @public (undocumented) type Listener = (promise: QueryRefPromise) => void; +// @public (undocumented) +type Listener_2 = (promise: FragmentRefPromise) => void; + // @public (undocumented) class LocalState { // Warning: (ae-forgotten-export) The symbol "LocalStateOptions" needs to be exported by the entry point index.d.ts @@ -1357,8 +1415,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1375,8 +1434,9 @@ interface ObservableQueryFields { reobserve: (newOptions?: Partial>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1506,7 +1566,9 @@ const QUERY_REFERENCE_SYMBOL: unique symbol; interface QueryFunctionOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -1766,8 +1828,6 @@ export interface QueryReference extends Q toPromise?: unknown; } -// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts -// // @public (undocumented) type QueryRefPromise = PromiseWithState>>; @@ -1973,15 +2033,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreFunction = ObservableQueryFields["subscribeToMore"]; +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1999,6 +2079,13 @@ class SuspenseCache { constructor(options?: SuspenseCacheOptions); // (undocumented) add(cacheKey: CacheKey, queryRef: InternalQueryReference): void; + // Warning: (ae-forgotten-export) The symbol "FragmentCacheKey" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "FragmentReference" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getFragmentRef(cacheKey: FragmentCacheKey, client: ApolloClient, options: WatchFragmentOptions & { + from: string; + }): FragmentReference; // (undocumented) getQueryRef(cacheKey: CacheKey, createObservable: () => ObservableQuery): InternalQueryReference; } @@ -2098,12 +2185,22 @@ export function unwrapQueryRef(queryRef: Partial>) type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) export function updateWrappedQueryRef(queryRef: WrappedQueryRef, promise: QueryRefPromise): void; @@ -2203,8 +2300,6 @@ function useFragment(options: UseFragme // @public (undocumented) interface UseFragmentOptions extends Omit, NoInfer_2>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { client?: ApolloClient; - // Warning: (ae-forgotten-export) The symbol "FragmentType" needs to be exported by the entry point index.d.ts - // // (undocumented) from: StoreObject | Reference | FragmentType> | string | null; // (undocumented) @@ -2251,6 +2346,41 @@ interface UseReadQueryResult { networkStatus: NetworkStatus; } +// Warning: (ae-forgotten-export) The symbol "UseSuspenseFragmentOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "UseSuspenseFragmentResult" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: NonNullable>; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: From; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +type UseSuspenseFragmentOptions = { + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; + from: From; + optimistic?: boolean; + client?: ApolloClient; +} & VariablesOption>; + +// @public (undocumented) +type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // Warning: (ae-forgotten-export) The symbol "SuspenseQueryHookOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "UseSuspenseQueryResult" needs to be exported by the entry point index.d.ts // @@ -2318,7 +2448,7 @@ type VariablesOption = [ TVariables ] extends [never] ? { variables?: Record; -} : {} extends OnlyRequiredProperties ? { +} : Record extends OnlyRequiredProperties ? { variables?: TVariables; } : { variables: TVariables; @@ -2378,6 +2508,10 @@ interface WrappableHooks { // // (undocumented) useReadQuery: typeof useReadQuery; + // Warning: (ae-forgotten-export) The symbol "useSuspenseFragment" needs to be exported by the entry point index.d.ts + // + // (undocumented) + useSuspenseFragment: typeof useSuspenseFragment; // Warning: (ae-forgotten-export) The symbol "useSuspenseQuery" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2406,18 +2540,18 @@ export function wrapQueryRef(inter // src/cache/core/types/common.ts:104:3 - (ae-forgotten-export) The symbol "ToReferenceFunction" needs to be exported by the entry point index.d.ts // src/cache/core/types/common.ts:105:3 - (ae-forgotten-export) The symbol "StorageType" needs to be exported by the entry point index.d.ts // 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:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:54:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:78:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts -// src/react/query-preloader/createQueryPreloader.ts:145:3 - (ae-forgotten-export) The symbol "PreloadQueryFetchPolicy" needs to be exported by the entry point index.d.ts -// src/react/query-preloader/createQueryPreloader.ts:167:5 - (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:357:2 - (ae-forgotten-export) The symbol "UpdateQueryOptions" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:51:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:75:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useSuspenseFragment.ts:70:5 - (ae-forgotten-export) The symbol "From" needs to be exported by the entry point index.d.ts +// src/react/query-preloader/createQueryPreloader.ts:77:5 - (ae-forgotten-export) The symbol "PreloadQueryFetchPolicy" needs to be exported by the entry point index.d.ts +// src/react/query-preloader/createQueryPreloader.ts:117:3 - (ae-forgotten-export) The symbol "RefetchWritePolicy" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index 9c6b605a377..e02d9d2905d 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -1245,8 +1245,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1263,8 +1264,9 @@ interface ObservableQueryFields { reobserve: (newOptions?: Partial>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -1329,7 +1331,9 @@ interface QueryDataOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -1776,12 +1780,35 @@ Item type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} + +// @public (undocumented) +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1855,12 +1882,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -1906,13 +1943,13 @@ interface WatchQueryOptions(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1824,12 +1825,29 @@ type StoreValue = number | string | string[] | Reference | Reference[] | null | export function subscribeAndCount(reject: (reason: any) => any, observable: Observable, cb: (handleCount: number, result: TResult) => any): Subscription; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1906,12 +1924,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -1974,13 +2002,13 @@ export function withWarningSpy(it: (...args: TArgs // src/cache/core/types/common.ts:104:3 - (ae-forgotten-export) The symbol "ToReferenceFunction" needs to be exported by the entry point index.d.ts // src/cache/core/types/common.ts:105:3 - (ae-forgotten-export) The symbol "StorageType" needs to be exported by the entry point index.d.ts // 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:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:357:2 - (ae-forgotten-export) The symbol "UpdateQueryOptions" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 82e35138da4..fa5b233b320 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -1331,8 +1331,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1781,12 +1782,29 @@ type StoreValue = number | string | string[] | Reference | Reference[] | null | export function subscribeAndCount(reject: (reason: any) => any, observable: Observable, cb: (handleCount: number, result: TResult) => any): Subscription; // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -1863,12 +1881,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -1931,13 +1959,13 @@ export function withWarningSpy(it: (...args: TArgs // src/cache/core/types/common.ts:104:3 - (ae-forgotten-export) The symbol "ToReferenceFunction" needs to be exported by the entry point index.d.ts // src/cache/core/types/common.ts:105:3 - (ae-forgotten-export) The symbol "StorageType" needs to be exported by the entry point index.d.ts // 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:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:357:2 - (ae-forgotten-export) The symbol "UpdateQueryOptions" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index c8a866c726d..59b4ba4b1ba 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -2020,8 +2020,9 @@ class ObservableQuery(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + // Warning: (ae-forgotten-export) The symbol "UpdateQueryMapFn" needs to be exported by the entry point index.d.ts + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -2652,12 +2653,29 @@ class Stump extends Layer { } // @public (undocumented) -type SubscribeToMoreOptions = { +interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreUpdateQueryFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -2782,12 +2800,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} + +// @public (undocumented) +type UpdateQueryOptions = { + variables?: TVariables; +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) interface UriFunction { @@ -2876,13 +2904,13 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/writeToStore.ts:65:7 - (ae-forgotten-export) The symbol "MergeTree" needs to be exported by the entry point index.d.ts // 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:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:357:2 - (ae-forgotten-export) The symbol "UpdateQueryOptions" needs to be exported by the entry point index.d.ts // src/utilities/graphql/storeUtils.ts:226:12 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts // src/utilities/policies/pagination.ts:76:3 - (ae-forgotten-export) The symbol "TRelayEdge" needs to be exported by the entry point index.d.ts // src/utilities/policies/pagination.ts:77:3 - (ae-forgotten-export) The symbol "TRelayPageInfo" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index f17d91511c0..3c3a0f2d981 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -342,6 +342,7 @@ type BackgroundQueryHookOptionsNoInfer = ApolloCache> extends MutationSharedOptions { client?: ApolloClient; + // @deprecated ignoreResults?: boolean; notifyOnNetworkStatusChange?: boolean; onCompleted?: (data: MaybeMasked, clientOptions?: BaseMutationOptions) => void; @@ -1093,6 +1094,9 @@ TData }; } : never : never; +// @public (undocumented) +type From = StoreObject | Reference | FragmentType> | string | null; + // @public (undocumented) export const from: typeof ApolloLink.from; @@ -1461,7 +1465,9 @@ export interface LazyQueryHookExecOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; } @@ -1939,8 +1945,8 @@ export class ObservableQuery>): void; startPolling(pollInterval: number): void; stopPolling(): void; - subscribeToMore(options: SubscribeToMoreOptions): () => void; - updateQuery(mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; + updateQuery(mapFn: UpdateQueryMapFn): void; get variables(): TVariables | undefined; } @@ -1957,8 +1963,8 @@ export interface ObservableQueryFields>, newNetworkStatus?: NetworkStatus) => Promise>>; startPolling: (pollInterval: number) => void; stopPolling: () => void; - subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: (mapFn: (previousQueryResult: Unmasked, options: Pick, "variables">) => Unmasked) => void; + subscribeToMore: SubscribeToMoreFunction; + updateQuery: (mapFn: UpdateQueryMapFn) => void; variables: TVariables | undefined; } @@ -2112,8 +2118,6 @@ export interface PreloadQueryFunction { (query: DocumentNode | TypedDocumentNode, ...[options]: PreloadQueryOptionsArg>): PreloadedQueryRef; } -// Warning: (ae-forgotten-export) The symbol "VariablesOption" needs to be exported by the entry point index.d.ts -// // @public (undocumented) export type PreloadQueryOptions = { canonizeResults?: boolean; @@ -2176,7 +2180,9 @@ export interface QueryDataOptions extends BaseQueryOptions { // @internal (undocumented) defaultOptions?: Partial>; + // @deprecated onCompleted?: (data: MaybeMasked) => void; + // @deprecated onError?: (error: ApolloError) => void; skip?: boolean; } @@ -2731,15 +2737,33 @@ class Stump extends Layer { } // @public (undocumented) -type SubscribeToMoreFunction = ObservableQueryFields["subscribeToMore"]; +export interface SubscribeToMoreFunction { + // (undocumented) + (options: SubscribeToMoreOptions): () => void; +} // @public (undocumented) -export type SubscribeToMoreOptions = { +export interface SubscribeToMoreOptions { + // (undocumented) + context?: DefaultContext; + // (undocumented) document: DocumentNode | TypedDocumentNode; - variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + // (undocumented) onError?: (error: Error) => void; - context?: DefaultContext; + // (undocumented) + updateQuery?: SubscribeToMoreUpdateQueryFn; + // (undocumented) + variables?: TSubscriptionVariables; +} + +// @public (undocumented) +export type SubscribeToMoreUpdateQueryFn = { + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions & { + subscriptionData: { + data: Unmasked; + }; + }): Unmasked | void; }; // @public (undocumented) @@ -2886,18 +2910,22 @@ type UnwrapFragmentRefs = true extends IsAny ? TData : TData exten type UpdateQueries = MutationOptions["updateQueries"]; // @public (undocumented) -type UpdateQueryFn = (previousQueryResult: Unmasked, options: { - subscriptionData: { - data: Unmasked; - }; - variables?: TSubscriptionVariables; -}) => Unmasked; +export interface UpdateQueryMapFn { + // (undocumented) + ( + unsafePreviousData: Unmasked, options: UpdateQueryOptions): Unmasked | void; +} // @public (undocumented) -export interface UpdateQueryOptions { - // (undocumented) +export type UpdateQueryOptions = { variables?: TVariables; -} +} & ({ + complete: true; + previousData: Unmasked; +} | { + complete: false; + previousData: DeepPartial> | undefined; +}); // @public (undocumented) export interface UriFunction { @@ -3083,6 +3111,40 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: NonNullable>; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: From; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +type UseSuspenseFragmentOptions = { + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; + from: From; + optimistic?: boolean; + client?: ApolloClient; +} & VariablesOption>; + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; @@ -3143,11 +3205,11 @@ export interface UseSuspenseQueryResult = [ +export type VariablesOption = [ TVariables ] extends [never] ? { variables?: Record; -} : {} extends OnlyRequiredProperties ? { +} : Record extends OnlyRequiredProperties ? { variables?: TVariables; } : { variables: TVariables; @@ -3219,16 +3281,15 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/policies.ts:162:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:162:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:118:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:119:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:277:2 - (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:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:54:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:78:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:51:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:75:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts // src/react/hooks/useLoadableQuery.ts:120:9 - (ae-forgotten-export) The symbol "ResetFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useSuspenseFragment.ts:70:5 - (ae-forgotten-export) The symbol "From" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/blue-comics-train.md b/.changeset/blue-comics-train.md new file mode 100644 index 00000000000..533df0e9022 --- /dev/null +++ b/.changeset/blue-comics-train.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Adds a new `useSuspenseFragment` hook. + +`useSuspenseFragment` suspends until `data` is complete. It is a drop-in replacement for `useFragment` when you prefer to use Suspense to control the loading state of a fragment. diff --git a/.changeset/bright-guests-chew.md b/.changeset/bright-guests-chew.md new file mode 100644 index 00000000000..2d4c565a396 --- /dev/null +++ b/.changeset/bright-guests-chew.md @@ -0,0 +1,16 @@ +--- +"@apollo/client": patch +--- + +Fix the return type of the `updateQuery` function to allow for `undefined`. `updateQuery` had the ability to bail out of the update by returning a falsey value, but the return type enforced a query value. + +```ts +observableQuery.updateQuery((unsafePreviousData, { previousData, complete }) => { + if (!complete) { + // Bail out of the update by returning early + return; + } + + // ... +}); +``` diff --git a/.changeset/fluffy-worms-fail.md b/.changeset/fluffy-worms-fail.md new file mode 100644 index 00000000000..b232d671f0e --- /dev/null +++ b/.changeset/fluffy-worms-fail.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Ensure errors thrown in the `onCompleted` callback from `useMutation` don't call `onError`. diff --git a/.changeset/heavy-pumas-boil.md b/.changeset/heavy-pumas-boil.md new file mode 100644 index 00000000000..8c523755db0 --- /dev/null +++ b/.changeset/heavy-pumas-boil.md @@ -0,0 +1,6 @@ +--- +"@apollo/client": minor +--- + +Deprecate the `onCompleted` and `onError` callbacks of `useQuery` and `useLazyQuery`. +For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. diff --git a/.changeset/khaki-cars-develop.md b/.changeset/khaki-cars-develop.md new file mode 100644 index 00000000000..09022ce6b89 --- /dev/null +++ b/.changeset/khaki-cars-develop.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": patch +--- + +Deprecate option `ignoreResults` in `useMutation`. +Once this option is removed, existing code still using it might see increase in re-renders. +If you don't want to synchronize your component state with the mutation, please use `useApolloClient` to get your ApolloClient instance and call `client.mutate` directly. diff --git a/.changeset/pretty-planets-cough.md b/.changeset/pretty-planets-cough.md new file mode 100644 index 00000000000..b7b76ba4aa6 --- /dev/null +++ b/.changeset/pretty-planets-cough.md @@ -0,0 +1,24 @@ +--- +"@apollo/client": minor +--- + +Provide a more type-safe option for the previous data value passed to `observableQuery.updateQuery`. Using it could result in crashes at runtime as this callback could be called with partial data even though its type reported the value as a complete result. + +The `updateQuery` callback function is now called with a new type-safe `previousData` property and a new `complete` property in the 2nd argument that determines whether `previousData` is a complete or partial result. + +As a result of this change, it is recommended to use the `previousData` property passed to the 2nd argument of the callback rather than using the previous data value from the first argument since that value is not type-safe. The first argument is now deprecated and will be removed in a future version of Apollo Client. + +```ts +observableQuery.updateQuery((unsafePreviousData, { previousData, complete }) => { + previousData + // ^? TData | DeepPartial | undefined + + if (complete) { + previousData + // ^? TData + } else { + previousData + // ^? DeepPartial | undefined + } +}) +``` diff --git a/.changeset/quiet-apricots-reply.md b/.changeset/quiet-apricots-reply.md new file mode 100644 index 00000000000..2d52fd9f0d0 --- /dev/null +++ b/.changeset/quiet-apricots-reply.md @@ -0,0 +1,6 @@ +--- +"@apollo/client": patch +--- + +In case of a multipart response (e.g. with `@defer`), query deduplication will +now keep going until the final chunk has been received. diff --git a/.changeset/sharp-windows-switch.md b/.changeset/sharp-windows-switch.md new file mode 100644 index 00000000000..7b47f0c978b --- /dev/null +++ b/.changeset/sharp-windows-switch.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Reject the mutation promise if errors are thrown in the `onCompleted` callback of `useMutation`. diff --git a/.changeset/tough-years-destroy.md b/.changeset/tough-years-destroy.md new file mode 100644 index 00000000000..90dfab53bb8 --- /dev/null +++ b/.changeset/tough-years-destroy.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix the type of the `variables` property passed as the 2nd argument to the `subscribeToMore` callback. This was previously reported as the `variables` type for the subscription itself, but is now properly typed as the query `variables`. diff --git a/.size-limits.json b/.size-limits.json index 6f950d7282a..ec77a686633 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41649, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34394 + "dist/apollo-client.min.cjs": 42240, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34448 } diff --git a/.vscode/settings.json b/.vscode/settings.json index c2580b79482..604746d4cfa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,9 @@ "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "typescript.tsdk": "node_modules/typescript/lib", + "editor.codeActionsOnSave": { + "source.organizeImports": "never" + }, "cSpell.enableFiletypes": ["mdx"], "jest.jestCommandLine": "node --expose-gc node_modules/.bin/jest --config ./config/jest.config.js --ignoreProjects 'ReactDOM 17' --runInBand" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 090568c7df4..db3af4247c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # @apollo/client +## 3.13.0-rc.0 + +### Minor Changes + +- [#12066](https://github.com/apollographql/apollo-client/pull/12066) [`c01da5d`](https://github.com/apollographql/apollo-client/commit/c01da5da639d4d9e882d380573b7876df4a1d65b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Adds a new `useSuspenseFragment` hook. + + `useSuspenseFragment` suspends until `data` is complete. It is a drop-in replacement for `useFragment` when you prefer to use Suspense to control the loading state of a fragment. + +- [#12174](https://github.com/apollographql/apollo-client/pull/12174) [`ba5cc33`](https://github.com/apollographql/apollo-client/commit/ba5cc330f8734a989eef71e883861f848388ac0c) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure errors thrown in the `onCompleted` callback from `useMutation` don't call `onError`. + +- [#12340](https://github.com/apollographql/apollo-client/pull/12340) [`716d02e`](https://github.com/apollographql/apollo-client/commit/716d02ec9c5b1448f50cb50a0306a345310a2342) Thanks [@phryneas](https://github.com/phryneas)! - Deprecate the `onCompleted` and `onError` callbacks of `useQuery` and `useLazyQuery`. + For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. + +- [#12276](https://github.com/apollographql/apollo-client/pull/12276) [`670f112`](https://github.com/apollographql/apollo-client/commit/670f112a7d9d85cb357eb279a488ac2c6d0137a9) Thanks [@Cellule](https://github.com/Cellule)! - Provide a more type-safe option for the previous data value passed to `observableQuery.updateQuery`. Using it could result in crashes at runtime as this callback could be called with partial data even though its type reported the value as a complete result. + + The `updateQuery` callback function is now called with a new type-safe `previousData` property and a new `complete` property in the 2nd argument that determines whether `previousData` is a complete or partial result. + + As a result of this change, it is recommended to use the `previousData` property passed to the 2nd argument of the callback rather than using the previous data value from the first argument since that value is not type-safe. The first argument is now deprecated and will be removed in a future version of Apollo Client. + + ```ts + observableQuery.updateQuery( + (unsafePreviousData, { previousData, complete }) => { + previousData; + // ^? TData | DeepPartial | undefined + + if (complete) { + previousData; + // ^? TData + } else { + previousData; + // ^? DeepPartial | undefined + } + } + ); + ``` + +- [#12174](https://github.com/apollographql/apollo-client/pull/12174) [`ba5cc33`](https://github.com/apollographql/apollo-client/commit/ba5cc330f8734a989eef71e883861f848388ac0c) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Reject the mutation promise if errors are thrown in the `onCompleted` callback of `useMutation`. + +### Patch Changes + +- [#12276](https://github.com/apollographql/apollo-client/pull/12276) [`670f112`](https://github.com/apollographql/apollo-client/commit/670f112a7d9d85cb357eb279a488ac2c6d0137a9) Thanks [@Cellule](https://github.com/Cellule)! - Fix the return type of the `updateQuery` function to allow for `undefined`. `updateQuery` had the ability to bail out of the update by returning a falsey value, but the return type enforced a query value. + + ```ts + observableQuery.updateQuery( + (unsafePreviousData, { previousData, complete }) => { + if (!complete) { + // Bail out of the update by returning early + return; + } + + // ... + } + ); + ``` + +- [#12296](https://github.com/apollographql/apollo-client/pull/12296) [`2422df2`](https://github.com/apollographql/apollo-client/commit/2422df202a7ec71365d5a8ab5b3b554fcf60e4af) Thanks [@Cellule](https://github.com/Cellule)! - Deprecate option `ignoreResults` in `useMutation`. + Once this option is removed, existing code still using it might see increase in re-renders. + If you don't want to synchronize your component state with the mutation, please use `useApolloClient` to get your ApolloClient instance and call `client.mutate` directly. + +- [#12338](https://github.com/apollographql/apollo-client/pull/12338) [`67c16c9`](https://github.com/apollographql/apollo-client/commit/67c16c93897e36be980ba2139ee8bd3f24ab8558) Thanks [@phryneas](https://github.com/phryneas)! - In case of a multipart response (e.g. with `@defer`), query deduplication will + now keep going until the final chunk has been received. + +- [#12276](https://github.com/apollographql/apollo-client/pull/12276) [`670f112`](https://github.com/apollographql/apollo-client/commit/670f112a7d9d85cb357eb279a488ac2c6d0137a9) Thanks [@Cellule](https://github.com/Cellule)! - Fix the type of the `variables` property passed as the 2nd argument to the `subscribeToMore` `updateQuery` callback. This was previously reported as the `variables` type for the subscription itself, but is now properly typed as the query `variables`. + ## 3.12.11 ### Patch Changes diff --git a/config/jest.config.js b/config/jest.config.js index 977c2e8e80a..011836cc41f 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -44,6 +44,7 @@ const react17TestFileIgnoreList = [ // We only support Suspense with React 18, so don't test suspense hooks with // React 17 "src/testing/experimental/__tests__/createTestSchema.test.tsx", + "src/react/hooks/__tests__/useSuspenseFragment.test.tsx", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", diff --git a/docs/source/data/fragments.mdx b/docs/source/data/fragments.mdx index d8f4fdf4373..4124d0eb53a 100644 --- a/docs/source/data/fragments.mdx +++ b/docs/source/data/fragments.mdx @@ -561,7 +561,7 @@ function Item(props: { item: { __typename: 'Item', id: number }}) { const { complete, data } = useFragment({ fragment: ItemFragment, fragmentName: "ItemFragment", - from: item + from: props.item }); return
  • {complete ? data.text : "incomplete"}
  • ; @@ -573,7 +573,7 @@ function Item(props) { const { complete, data } = useFragment({ fragment: ITEM_FRAGMENT, fragmentName: "ItemFragment", - from: item + from: props.item }); return
  • {complete ? data.text : "incomplete"}
  • ; @@ -589,6 +589,71 @@ function Item(props) { See the [API reference](../api/react/hooks#usefragment) for more details on the supported options. + +## `useSuspenseFragment` + + +For those that have integrated with React [Suspense](https://react.dev/reference/react/Suspense), `useSuspenseFragment` is available as a drop-in replacement for `useFragment`. `useSuspenseFragment` works identically to `useFragment` but will suspend while `data` is incomplete. + +Let's update the example from the previous section to use `useSuspenseFragment`. First, we'll update our `Item` component and replace `useFragment` with `useSuspenseFragment`. Since we are using Suspense, we no longer have to check for a `complete` property to determine if the result is complete because the component will suspend otherwise. + +```tsx +import { useSuspenseFragment } from "@apollo/client"; + +function Item(props) { + const { data } = useSuspenseFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: props.item + }); + + return
  • {data.text}
  • ; +} +``` + +Next, we'll will wrap our `Item` components in a `Suspense` boundary to show a loading indicator if the data from `ItemFragment` is not complete. Since we're using Suspense, we'll replace `useQuery` with `useSuspenseQuery` as well: + +```tsx +function List() { + const { data } = useSuspenseQuery(listQuery); + + return ( +
      + {data.list.map(item => ( + }> + + + ))} +
    + ); +} +``` + +And that's it! Suspense made our `Item` component a bit more succinct since we no longer need to check the `complete` property to determine if we can safely use `data`. + + +In most cases, `useSuspenseFragment` will not suspend when rendered as a child of a query component. In this example `useSuspenseQuery` loads the full query data before each `Item` is rendered so the `data` inside each fragment is already complete. The `Suspense` boundary in this example ensures that a loading spinner is shown if field data is removed for any given item in the list in the cache, such as when a manual cache update is performed. + + +### Using `useSuspenseFragment` with `@defer` + +`useSuspenseFragment` is helpful when combined with the [`@defer` directive](./directives#defer) to show a loading state while the fragment data is streamed to the query. Let's update our `GetItemList` query to defer loading the `ItemFragment`'s fields. + +```graphql +query GetItemList { + list { + id + ...ItemFragment @defer + } +} +``` + +Our list will now render as soon as our list returns but before the data for `ItemFragment` is loaded. + + +You **must** ensure that any key fields used to identify the object passed to the `from` option are not deferred. If they are, you risk suspending the `useSuspenseFragment` hook forever. If you need to defer loading key fields, conditionally render the component until the object passed to the `from` option is identifiable by the cache. + + ## Data masking diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index 72d551c4b9b..fce6521d2af 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -11,7 +11,7 @@ import { } from "../core"; import { Kind } from "graphql"; -import { Observable } from "../utilities"; +import { DeepPartial, Observable } from "../utilities"; import { ApolloLink, FetchResult } from "../link/core"; import { HttpLink } from "../link/http"; import { createFragmentRegistry, InMemoryCache } from "../cache"; @@ -3218,25 +3218,39 @@ describe("ApolloClient", () => { UnmaskedQuery | undefined >(); - observableQuery.updateQuery((previousData) => { - expectTypeOf(previousData).toMatchTypeOf(); - expectTypeOf(previousData).not.toMatchTypeOf(); + observableQuery.updateQuery( + (_previousData, { complete, previousData }) => { + expectTypeOf(_previousData).toEqualTypeOf(); + expectTypeOf(_previousData).not.toMatchTypeOf(); - return {} as UnmaskedQuery; - }); + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + } + ); observableQuery.subscribeToMore({ document: subscription, - updateQuery(queryData, { subscriptionData }) { - expectTypeOf(queryData).toMatchTypeOf(); + updateQuery(queryData, { subscriptionData, complete, previousData }) { + expectTypeOf(queryData).toEqualTypeOf(); expectTypeOf(queryData).not.toMatchTypeOf(); + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + expectTypeOf( subscriptionData.data ).toMatchTypeOf(); expectTypeOf(subscriptionData.data).not.toMatchTypeOf(); - - return {} as UnmaskedQuery; }, }); }); diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index e21e634c864..379695ffb14 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -67,6 +67,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; @@ -293,6 +294,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; @@ -338,6 +340,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index 9c23822916b..8c386b67e6f 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -1891,8 +1891,16 @@ describe("client.watchQuery", () => { } const updateQuery: Parameters[0] = jest.fn( - (previousResult) => { - return { user: { ...previousResult.user, name: "User (updated)" } }; + (previousResult, { complete, previousData }) => { + expect(complete).toBe(true); + expect(previousData).toStrictEqual(previousResult); + // Type Guard + if (!complete) { + return; + } + return { + user: { ...previousData.user, name: "User (updated)" }, + }; } ); @@ -1900,7 +1908,13 @@ describe("client.watchQuery", () => { expect(updateQuery).toHaveBeenCalledWith( { user: { __typename: "User", id: 1, name: "User 1", age: 30 } }, - { variables: { id: 1 } } + { + variables: { id: 1 }, + complete: true, + previousData: { + user: { __typename: "User", id: 1, name: "User 1", age: 30 }, + }, + } ); { @@ -4831,7 +4845,16 @@ describe("observableQuery.subscribeToMore", () => { }, }, { + complete: true, variables: {}, + previousData: { + recentComment: { + __typename: "Comment", + id: 1, + comment: "Recent comment", + author: "Test User", + }, + }, subscriptionData: { data: { addedComment: { @@ -4958,7 +4981,16 @@ describe("observableQuery.subscribeToMore", () => { }, }, { + complete: true, variables: {}, + previousData: { + recentComment: { + __typename: "Comment", + id: 1, + comment: "Recent comment", + author: "Test User", + }, + }, subscriptionData: { data: { addedComment: { @@ -5006,8 +5038,8 @@ describe("observableQuery.subscribeToMore", () => { `; const subscription = gql` - subscription NewCommentSubscription { - addedComment { + subscription NewCommentSubscription($id: ID!) { + addedComment(id: $id) { id ...CommentFields } @@ -5060,7 +5092,11 @@ describe("observableQuery.subscribeToMore", () => { return { recentComment: subscriptionData.data.addedComment }; }); - observable.subscribeToMore({ document: subscription, updateQuery }); + observable.subscribeToMore({ + document: subscription, + updateQuery, + variables: { id: 1 }, + }); subscriptionLink.simulateResult({ result: { @@ -5087,7 +5123,16 @@ describe("observableQuery.subscribeToMore", () => { }, }, { + complete: true, variables: {}, + previousData: { + recentComment: { + __typename: "Comment", + id: 1, + comment: "Recent comment", + author: "Test User", + }, + }, subscriptionData: { data: { addedComment: { diff --git a/src/__tests__/subscribeToMore.ts b/src/__tests__/subscribeToMore.ts index 6216eaa85c9..9761ee3f5a6 100644 --- a/src/__tests__/subscribeToMore.ts +++ b/src/__tests__/subscribeToMore.ts @@ -240,10 +240,17 @@ describe("subscribeToMore", () => { name } `, - updateQuery: (prev, { subscriptionData }) => { - expect(prev.entry).not.toContainEqual(nextMutation); + updateQuery: (prev, { subscriptionData, complete, previousData }) => { + expect(complete).toBe(true); + expect(previousData).toStrictEqual(prev); + // Type Guard + if (!complete) { + return; + } + + expect(previousData.entry).not.toContainEqual(nextMutation); return { - entry: [...prev.entry, { value: subscriptionData.data.name }], + entry: [...previousData.entry, { value: subscriptionData.data.name }], }; }, }); diff --git a/src/config/jest/setup.ts b/src/config/jest/setup.ts index 141d0e4132d..4c1ed3e5581 100644 --- a/src/config/jest/setup.ts +++ b/src/config/jest/setup.ts @@ -36,3 +36,6 @@ if (!Symbol.asyncDispose) { // @ts-ignore expect.addEqualityTesters([areApolloErrorsEqual, areGraphQLErrorsEqual]); + +// not available in JSDOM 🙄 +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 4397c518518..78e1ebd2304 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -31,6 +31,8 @@ import type { SubscribeToMoreOptions, NextFetchPolicyContext, WatchQueryFetchPolicy, + UpdateQueryMapFn, + UpdateQueryOptions, } from "./watchQueryOptions.js"; import type { QueryInfo } from "./QueryInfo.js"; import type { MissingFieldError } from "../cache/index.js"; @@ -54,10 +56,6 @@ export interface FetchMoreOptions< ) => TData; } -export interface UpdateQueryOptions { - variables?: TVariables; -} - interface Last { result: ApolloQueryResult; variables?: TVariables; @@ -624,9 +622,10 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, options: SubscribeToMoreOptions< TData, TSubscriptionVariables, - TSubscriptionData + TSubscriptionData, + TVariables > - ) { + ): () => void { const subscription = this.queryManager .startGraphQLSubscription({ query: options.document, @@ -637,12 +636,11 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, next: (subscriptionData: { data: Unmasked }) => { const { updateQuery } = options; if (updateQuery) { - this.updateQuery( - (previous, { variables }) => - updateQuery(previous, { - subscriptionData, - variables, - }) + this.updateQuery((previous, updateOptions) => + updateQuery(previous, { + subscriptionData, + ...updateOptions, + }) ); } }, @@ -727,23 +725,23 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, * * See [using updateQuery and updateFragment](https://www.apollographql.com/docs/react/caching/cache-interaction/#using-updatequery-and-updatefragment) for additional information. */ - public updateQuery( - mapFn: ( - previousQueryResult: Unmasked, - options: Pick, "variables"> - ) => Unmasked - ): void { + public updateQuery(mapFn: UpdateQueryMapFn): void { const { queryManager } = this; - const { result } = queryManager.cache.diff({ + const { result, complete } = queryManager.cache.diff({ query: this.options.query, variables: this.variables, returnPartialData: true, optimistic: false, }); - const newResult = mapFn(result! as Unmasked, { - variables: (this as any).variables, - }); + const newResult = mapFn( + result! as Unmasked, + { + variables: this.variables, + complete: !!complete, + previousData: result, + } as UpdateQueryOptions + ); if (newResult) { queryManager.cache.writeQuery({ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 066dc137de9..5a32bad1cca 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1182,8 +1182,12 @@ export class QueryManager { ]); observable = entry.observable = concast; - concast.beforeNext(() => { - inFlightLinkObservables.remove(printedServerQuery, varJson); + concast.beforeNext(function cb(method, arg: FetchResult) { + if (method === "next" && "hasNext" in arg && arg.hasNext) { + concast.beforeNext(cb); + } else { + inFlightLinkObservables.remove(printedServerQuery, varJson); + } }); } } else { diff --git a/src/core/__tests__/ApolloClient/general.test.ts b/src/core/__tests__/ApolloClient/general.test.ts index e18dab64fd7..a11cdde119b 100644 --- a/src/core/__tests__/ApolloClient/general.test.ts +++ b/src/core/__tests__/ApolloClient/general.test.ts @@ -9,7 +9,11 @@ import { Observable, Observer, } from "../../../utilities/observables/Observable"; -import { ApolloLink, FetchResult } from "../../../link/core"; +import { + ApolloLink, + FetchResult, + type RequestHandler, +} from "../../../link/core"; import { InMemoryCache } from "../../../cache"; // mocks @@ -31,7 +35,11 @@ import { wait } from "../../../testing/core"; import { ApolloClient } from "../../../core"; import { mockFetchQuery } from "../ObservableQuery"; import { Concast, print } from "../../../utilities"; -import { ObservableStream, spyOnConsole } from "../../../testing/internal"; +import { + mockDeferStream, + ObservableStream, + spyOnConsole, +} from "../../../testing/internal"; describe("ApolloClient", () => { const getObservableStream = ({ @@ -6522,6 +6530,129 @@ describe("ApolloClient", () => { ) ).toBeUndefined(); }); + + it("deduplicates queries as long as a query still has deferred chunks", async () => { + const query = gql` + query LazyLoadLuke { + people(id: 1) { + id + name + friends { + id + ... @defer { + name + } + } + } + } + `; + + const outgoingRequestSpy = jest.fn(((operation, forward) => + forward(operation)) satisfies RequestHandler); + const defer = mockDeferStream(); + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), + }); + + const query1 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + const query2 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const initialData = { + people: { + __typename: "Person", + id: 1, + name: "Luke", + friends: [ + { + __typename: "Person", + id: 5, + } as { __typename: "Person"; id: number; name?: string }, + { + __typename: "Person", + id: 8, + } as { __typename: "Person"; id: number; name?: string }, + ], + }, + }; + const initialResult = { + data: initialData, + loading: false, + networkStatus: 7, + }; + + defer.enqueueInitialChunk({ + data: initialData, + hasNext: true, + }); + + await expect(query1).toEmitFetchResult(initialResult); + await expect(query2).toEmitFetchResult(initialResult); + + const query3 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query3).toEmitFetchResult(initialResult); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const firstChunk = { + incremental: [ + { + data: { + name: "Leia", + }, + path: ["people", "friends", 0], + }, + ], + hasNext: true, + }; + const resultAfterFirstChunk = structuredClone(initialResult); + resultAfterFirstChunk.data.people.friends[0].name = "Leia"; + + defer.enqueueSubsequentChunk(firstChunk); + + await expect(query1).toEmitFetchResult(resultAfterFirstChunk); + await expect(query2).toEmitFetchResult(resultAfterFirstChunk); + await expect(query3).toEmitFetchResult(resultAfterFirstChunk); + + const query4 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + expect(query4).toEmitFetchResult(resultAfterFirstChunk); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const secondChunk = { + incremental: [ + { + data: { + name: "Han Solo", + }, + path: ["people", "friends", 1], + }, + ], + hasNext: false, + }; + const resultAfterSecondChunk = structuredClone(resultAfterFirstChunk); + resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; + + defer.enqueueSubsequentChunk(secondChunk); + + await expect(query1).toEmitFetchResult(resultAfterSecondChunk); + await expect(query2).toEmitFetchResult(resultAfterSecondChunk); + await expect(query3).toEmitFetchResult(resultAfterSecondChunk); + await expect(query4).toEmitFetchResult(resultAfterSecondChunk); + + const query5 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + expect(query5).not.toEmitAnything(); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); + }); }); describe("missing cache field warnings", () => { diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 133165db818..c816957a898 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -12,6 +12,7 @@ import { ObservableQuery } from "../ObservableQuery"; import { QueryManager } from "../QueryManager"; import { + DeepPartial, DocumentTransform, Observable, removeDirectivesFromDocument, @@ -21,6 +22,7 @@ import { InMemoryCache } from "../../cache"; import { ApolloError } from "../../errors"; import { MockLink, MockSubscriptionLink, tick, wait } from "../../testing"; +import { expectTypeOf } from "expect-type"; import { SubscriptionObserver } from "zen-observable-ts"; import { waitFor } from "@testing-library/react"; @@ -3350,6 +3352,68 @@ describe("ObservableQuery", () => { }); }); + describe("updateQuery", () => { + it("should be able to determine if the previous result is complete", async () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ addTypename: false }), + link: new MockLink([ + { + request: { query, variables }, + result: { data: dataOne }, + }, + ]), + }); + + const observable = client.watchQuery({ + query, + variables, + }); + + let updateQuerySpy = jest.fn(); + observable.updateQuery((previous, { complete, previousData }) => { + updateQuerySpy(); + expect(previous).toEqual({}); + expect(complete).toBe(false); + expect(previousData).toStrictEqual(previous); + + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + }); + + observable.subscribe(jest.fn()); + + await waitFor(() => { + expect(observable.getCurrentResult(false)).toEqual({ + data: dataOne, + loading: false, + networkStatus: NetworkStatus.ready, + }); + }); + + observable.updateQuery((previous, { complete, previousData }) => { + updateQuerySpy(); + expect(previous).toEqual(dataOne); + expect(complete).toBe(true); + expect(previousData).toStrictEqual(previous); + + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + }); + + expect(updateQuerySpy).toHaveBeenCalledTimes(2); + }); + }); + it("QueryInfo does not notify for !== but deep-equal results", async () => { const client = new ApolloClient({ cache: new InMemoryCache({ addTypename: false }), diff --git a/src/core/index.ts b/src/core/index.ts index 3b0143ae6c1..9c6ac111d18 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,10 +2,7 @@ export type { ApolloClientOptions, DefaultOptions } from "./ApolloClient.js"; export { ApolloClient, mergeOptions } from "./ApolloClient.js"; -export type { - FetchMoreOptions, - UpdateQueryOptions, -} from "./ObservableQuery.js"; +export type { FetchMoreOptions } from "./ObservableQuery.js"; export { ObservableQuery } from "./ObservableQuery.js"; export type { QueryOptions, @@ -19,6 +16,10 @@ export type { ErrorPolicy, FetchMoreQueryOptions, SubscribeToMoreOptions, + SubscribeToMoreFunction, + UpdateQueryMapFn, + UpdateQueryOptions, + SubscribeToMoreUpdateQueryFn, } from "./watchQueryOptions.js"; export { NetworkStatus, isNetworkRequestSettled } from "./networkStatus.js"; export type * from "./types.js"; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 1528b1d0330..f03448a73a0 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -14,7 +14,7 @@ import type { ApolloCache } from "../cache/index.js"; import type { ObservableQuery } from "./ObservableQuery.js"; import type { IgnoreModifier } from "../cache/core/types/common.js"; import type { Unmasked } from "../masking/index.js"; -import type { NoInfer } from "../utilities/index.js"; +import type { DeepPartial, NoInfer } from "../utilities/index.js"; /** * fetchPolicy determines where the client may return a result from. The options are: @@ -164,6 +164,11 @@ export interface FetchMoreQueryOptions { context?: DefaultContext; } +/** + * @deprecated `UpdateQueryFn` is deprecated and will be removed or updated in a + * future version of Apollo Client. Use `SubscribeToMoreUpdateQueryFn` instead + * which provides a more type-safe result. + */ export type UpdateQueryFn< TData = any, TSubscriptionVariables = OperationVariables, @@ -176,19 +181,94 @@ export type UpdateQueryFn< } ) => Unmasked; -export type SubscribeToMoreOptions< +export type UpdateQueryOptions = { + variables?: TVariables; +} & ( + | { + /** + * Indicate if the previous query result has been found fully in the cache. + */ + complete: true; + previousData: Unmasked; + } + | { + /** + * Indicate if the previous query result has not been found fully in the cache. + * Might have partial or missing data. + */ + complete: false; + previousData: DeepPartial> | undefined; + } +); + +export interface UpdateQueryMapFn< TData = any, - TSubscriptionVariables = OperationVariables, + TVariables = OperationVariables, +> { + ( + /** + * @deprecated This value is not type-safe and may contain partial data. This + * argument will be removed in the next major version of Apollo Client. Use + * `options.previousData` instead for a more type-safe value. + */ + unsafePreviousData: Unmasked, + options: UpdateQueryOptions + ): Unmasked | void; +} + +export type SubscribeToMoreUpdateQueryFn< + TData = any, + TVariables extends OperationVariables = OperationVariables, TSubscriptionData = TData, > = { + ( + /** + * @deprecated This value is not type-safe and may contain partial data. This + * argument will be removed in the next major version of Apollo Client. Use + * `options.previousData` instead for a more type-safe value. + */ + unsafePreviousData: Unmasked, + options: UpdateQueryOptions & { + subscriptionData: { data: Unmasked }; + } + ): Unmasked | void; +}; + +export interface SubscribeToMoreOptions< + TData = any, + TSubscriptionVariables extends OperationVariables = OperationVariables, + TSubscriptionData = TData, + TVariables extends OperationVariables = TSubscriptionVariables, +> { document: | DocumentNode | TypedDocumentNode; variables?: TSubscriptionVariables; - updateQuery?: UpdateQueryFn; + updateQuery?: SubscribeToMoreUpdateQueryFn< + TData, + TVariables, + TSubscriptionData + >; onError?: (error: Error) => void; context?: DefaultContext; -}; +} + +export interface SubscribeToMoreFunction< + TData, + TVariables extends OperationVariables = OperationVariables, +> { + < + TSubscriptionData = TData, + TSubscriptionVariables extends OperationVariables = TVariables, + >( + options: SubscribeToMoreOptions< + TData, + TSubscriptionVariables, + TSubscriptionData, + TVariables + > + ): () => void; +} export interface SubscriptionOptions< TVariables = OperationVariables, diff --git a/src/react/hoc/types.ts b/src/react/hoc/types.ts index 0d67f4c1ca0..892727c0db7 100644 --- a/src/react/hoc/types.ts +++ b/src/react/hoc/types.ts @@ -1,13 +1,14 @@ -import type { ApolloCache, ApolloClient } from "../../core/index.js"; import type { ApolloError } from "../../errors/index.js"; import type { + ApolloCache, + ApolloClient, ApolloQueryResult, - OperationVariables, + DefaultContext, FetchMoreOptions, - UpdateQueryOptions, FetchMoreQueryOptions, + OperationVariables, SubscribeToMoreOptions, - DefaultContext, + UpdateQueryMapFn, } from "../../core/index.js"; import type { MutationFunction, @@ -32,9 +33,7 @@ export interface QueryControls< startPolling: (pollInterval: number) => void; stopPolling: () => void; subscribeToMore: (options: SubscribeToMoreOptions) => () => void; - updateQuery: ( - mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any - ) => void; + updateQuery: (mapFn: UpdateQueryMapFn) => void; } export type DataValue< diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index a335ede0877..5e788e03b56 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -42,6 +42,7 @@ import equal from "@wry/equality"; import { RefetchWritePolicy, SubscribeToMoreOptions, + SubscribeToMoreFunction, } from "../../../core/watchQueryOptions"; import { skipToken } from "../constants"; import { @@ -57,7 +58,6 @@ import { spyOnConsole, addDelayToMocks, } from "../../../testing/internal"; -import { SubscribeToMoreFunction } from "../useSuspenseQuery"; import { MaskedVariablesCaseData, setupMaskedVariablesCase, @@ -7190,6 +7190,8 @@ describe("fetchMore", () => { expect(updateQuery).toHaveBeenCalledWith( { greeting: "Hello" }, { + complete: true, + previousData: { greeting: "Hello" }, subscriptionData: { data: { greetingUpdated: "Subscription hello" }, }, @@ -8364,9 +8366,12 @@ describe.skip("type tests", () => { { const [, { subscribeToMore }] = useBackgroundQuery(query); - const subscription: MaskedDocumentNode = gql` - subscription { - pushLetter { + const subscription: MaskedDocumentNode< + Subscription, + { letterId: string } + > = gql` + subscription LetterPushed($letterId: ID!) { + pushLetter(letterId: $letterId) { id ...CharacterFragment } @@ -8379,15 +8384,41 @@ describe.skip("type tests", () => { subscribeToMore({ document: subscription, - updateQuery: (queryData, { subscriptionData }) => { + updateQuery: ( + queryData, + { subscriptionData, variables, complete, previousData } + ) => { expectTypeOf(queryData).toEqualTypeOf(); expectTypeOf(queryData).not.toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + | UnmaskedVariablesCaseData + | DeepPartial + | undefined + >(); + + if (complete) { + // Should narrow the type + expectTypeOf( + previousData + ).toEqualTypeOf(); + expectTypeOf( + previousData + ).not.toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } expectTypeOf( subscriptionData.data ).toEqualTypeOf(); expectTypeOf(subscriptionData.data).not.toEqualTypeOf(); + expectTypeOf(variables).toEqualTypeOf< + VariablesCaseVariables | undefined + >(); + return {} as UnmaskedVariablesCaseData; }, }); @@ -8411,16 +8442,43 @@ describe.skip("type tests", () => { subscribeToMore({ document: subscription, - updateQuery: (queryData, { subscriptionData }) => { + updateQuery: ( + queryData, + { subscriptionData, variables, complete, previousData } + ) => { expectTypeOf(queryData).toEqualTypeOf(); expectTypeOf(queryData).not.toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + | UnmaskedVariablesCaseData + | DeepPartial + | undefined + >(); + + if (complete) { + // Should narrow the type + expectTypeOf( + previousData + ).toEqualTypeOf(); + expectTypeOf( + previousData + ).not.toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + expectTypeOf( subscriptionData.data ).toEqualTypeOf(); expectTypeOf(subscriptionData.data).not.toEqualTypeOf(); - return {} as UnmaskedVariablesCaseData; + expectTypeOf(variables).toEqualTypeOf< + VariablesCaseVariables | undefined + >(); + + return queryData; }, }); } diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 802638fef98..a148a8256bc 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -12,7 +12,7 @@ import { NetworkStatus, TypedDocumentNode, } from "../../../core"; -import { Observable } from "../../../utilities"; +import { DeepPartial, Observable } from "../../../utilities"; import { ApolloProvider } from "../../../react"; import { MockedProvider, @@ -3351,8 +3351,20 @@ describe.skip("Type Tests", () => { subscribeToMore({ document: gql`` as TypedDocumentNode, - updateQuery(queryData, { subscriptionData }) { + updateQuery(queryData, { subscriptionData, complete, previousData }) { expectTypeOf(queryData).toEqualTypeOf(); + expectTypeOf(complete).toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + UnmaskedQuery | DeepPartial | undefined + >(); + + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } expectTypeOf( subscriptionData.data ).toEqualTypeOf(); @@ -3361,8 +3373,12 @@ describe.skip("Type Tests", () => { }, }); - updateQuery((previousData) => { - expectTypeOf(previousData).toEqualTypeOf(); + updateQuery((_previousData, { complete, previousData }) => { + expectTypeOf(_previousData).toEqualTypeOf(); + expectTypeOf(complete).toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + UnmaskedQuery | DeepPartial | undefined + >(); return {} as UnmaskedQuery; }); @@ -3450,20 +3466,41 @@ describe.skip("Type Tests", () => { subscribeToMore({ document: gql`` as TypedDocumentNode, - updateQuery(queryData, { subscriptionData }) { + updateQuery(queryData, { subscriptionData, complete, previousData }) { expectTypeOf(queryData).toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + UnmaskedQuery | DeepPartial | undefined + >(); expectTypeOf( subscriptionData.data ).toEqualTypeOf(); + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + return {} as UnmaskedQuery; }, }); - updateQuery((previousData) => { - expectTypeOf(previousData).toEqualTypeOf(); - - return {} as UnmaskedQuery; + updateQuery((_previousData, { complete, previousData }) => { + expectTypeOf(_previousData).toEqualTypeOf(); + expectTypeOf(complete).toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + UnmaskedQuery | DeepPartial | undefined + >(); + + if (complete) { + expectTypeOf(previousData).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } }); { diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index 86227f9775c..764edd1be8f 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -18,6 +18,7 @@ import { SubscribeToMoreOptions, split, } from "../../../core"; +import { SubscribeToMoreFunction } from "../../../core/watchQueryOptions"; import { MockedProvider, MockedProviderProps, @@ -39,11 +40,7 @@ import { ApolloProvider } from "../../context"; import { InMemoryCache } from "../../../cache"; import { LoadableQueryHookFetchPolicy } from "../../types/types"; import { QueryRef } from "../../../react"; -import { - FetchMoreFunction, - RefetchFunction, - SubscribeToMoreFunction, -} from "../useSuspenseQuery"; +import { FetchMoreFunction, RefetchFunction } from "../useSuspenseQuery"; import invariant, { InvariantError } from "ts-invariant"; import { SimpleCaseData, @@ -5097,6 +5094,8 @@ it("can subscribe to subscriptions and react to cache updates via `subscribeToMo expect(updateQuery).toHaveBeenCalledWith( { greeting: "Hello" }, { + complete: true, + previousData: { greeting: "Hello" }, subscriptionData: { data: { greetingUpdated: "Subscription hello" }, }, diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 2ac84fb45b8..0433d37b339 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -1206,6 +1206,61 @@ describe("useMutation Hook", () => { expect.objectContaining({ variables }) ); }); + + // https://github.com/apollographql/apollo-client/issues/12008 + it("does not call onError if errors are thrown in the onCompleted callback", async () => { + const CREATE_TODO_DATA = { + createTodo: { + id: 1, + priority: "Low", + description: "Get milk!", + __typename: "Todo", + }, + }; + + const variables = { + priority: "Low", + description: "Get milk2.", + }; + + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables, + }, + result: { + data: CREATE_TODO_DATA, + }, + }, + ]; + + const onError = jest.fn(); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useMutation(CREATE_TODO_MUTATION, { + onCompleted: () => { + throw new Error("Oops"); + }, + onError, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + const [createTodo] = await takeSnapshot(); + + await expect(createTodo({ variables })).rejects.toEqual( + new Error("Oops") + ); + + expect(onError).not.toHaveBeenCalled(); + }); }); describe("ROOT_MUTATION cache data", () => { diff --git a/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx b/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx index 48a8ac2ba64..f0db8609db3 100644 --- a/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx +++ b/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx @@ -4,11 +4,14 @@ import { ApolloClient, InMemoryCache, NetworkStatus, - SubscribeToMoreOptions, TypedDocumentNode, gql, split, } from "../../../core"; +import { + SubscribeToMoreFunction, + SubscribeToMoreUpdateQueryFn, +} from "../../../core/watchQueryOptions"; import { MockLink, MockSubscriptionLink, @@ -23,7 +26,6 @@ import { } from "../../../testing/internal"; import { useQueryRefHandlers } from "../useQueryRefHandlers"; import { UseReadQueryResult, useReadQuery } from "../useReadQuery"; -import type { SubscribeToMoreFunction } from "../useSuspenseQuery"; import { Suspense } from "react"; import { createQueryPreloader } from "../../query-preloader/createQueryPreloader"; import userEvent from "@testing-library/user-event"; @@ -1960,12 +1962,10 @@ test("can subscribe to subscriptions and react to cache updates via `subscribeTo greetingUpdated: string; } - type UpdateQueryFn = NonNullable< - SubscribeToMoreOptions< - SimpleCaseData, - Record, - SubscriptionData - >["updateQuery"] + type UpdateQueryFn = SubscribeToMoreUpdateQueryFn< + SimpleCaseData, + Record, + SubscriptionData >; const subscription: TypedDocumentNode< @@ -2092,6 +2092,8 @@ test("can subscribe to subscriptions and react to cache updates via `subscribeTo expect(updateQuery).toHaveBeenCalledWith( { greeting: "Hello" }, { + complete: true, + previousData: { greeting: "Hello" }, subscriptionData: { data: { greetingUpdated: "Subscription hello" }, }, diff --git a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx new file mode 100644 index 00000000000..d24d1804348 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx @@ -0,0 +1,2007 @@ +import { + useSuspenseFragment, + UseSuspenseFragmentResult, +} from "../useSuspenseFragment"; +import { + ApolloClient, + FragmentType, + gql, + InMemoryCache, + Masked, + MaskedDocumentNode, + MaybeMasked, + OperationVariables, + TypedDocumentNode, +} from "../../../core"; +import React, { Suspense } from "react"; +import { ApolloProvider } from "../../context"; +import { + createRenderStream, + disableActEnvironment, + renderHookToSnapshotStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import { renderAsync, spyOnConsole } from "../../../testing/internal"; +import { act, renderHook, screen, waitFor } from "@testing-library/react"; +import { InvariantError } from "ts-invariant"; +import { MockedProvider, MockSubscriptionLink, wait } from "../../../testing"; +import { expectTypeOf } from "expect-type"; +import userEvent from "@testing-library/user-event"; + +function createDefaultRenderStream() { + return createRenderStream({ + initialSnapshot: { + result: null as UseSuspenseFragmentResult> | null, + }, + }); +} + +function createDefaultTrackedComponents() { + function SuspenseFallback() { + useTrackRenders(); + return
    Loading
    ; + } + + return { SuspenseFallback }; +} + +test("validates the GraphQL document is a fragment", () => { + using _ = spyOnConsole("error"); + + const fragment = gql` + query ShouldThrow { + createException + } + `; + + expect(() => { + renderHook( + () => useSuspenseFragment({ fragment, from: { __typename: "Nope" } }), + { wrapper: ({ children }) => {children} } + ); + }).toThrow( + new InvariantError( + "Found a query operation named 'ShouldThrow'. No operations are allowed when using a fragment as a query. Only fragments are allowed." + ) + ); +}); + +test("throws if no client is provided", () => { + using _spy = spyOnConsole("error"); + expect(() => + renderHook(() => + useSuspenseFragment({ + fragment: gql` + fragment ShouldThrow on Error { + shouldThrow + } + `, + from: {}, + }) + ) + ).toThrow(/pass an ApolloClient/); +}); + +test("suspends until cache value is complete", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { render, takeRender, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => { + return {children}; + }, + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("updates when the cache updates", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("resuspends when data goes missing until complete again", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + client.cache.modify({ + id: "Item:1", + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns cache data when data is already in the cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Cached" }, + }); + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Cached", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("receives cache updates after initial result when data is written to the cache before mounted", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Cached" }, + }); + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Cached", + }, + }); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Updated" }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Updated", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("allows the client to be overridden", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const defaultClient = new ApolloClient({ cache: new InMemoryCache() }); + const client = new ApolloClient({ cache: new InMemoryCache() }); + + defaultClient.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Should not be used" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + client, + from: { __typename: "Item", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); +}); + +test("suspends until data is complete when changing `from` with no data written to cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const { takeRender, replaceSnapshot, render } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + function App({ id }: { id: number }) { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await rerender( + }> + + + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 2, + text: "Item #2", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend when changing `from` with data already written to cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const { takeRender, replaceSnapshot, render } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + using _disabledAct = disableActEnvironment(); + function App({ id }: { id: number }) { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await rerender( + }> + + + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 2, + text: "Item #2", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +it("does not rerender when fields with @nonreactive change", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text @nonreactive + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ fragment, from: { __typename: "Item", id: 1 } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it("does not rerender when fields with @nonreactive on nested fragment change", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemFields @nonreactive + } + + fragment ItemFields on Item { + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "ItemFragment", + from: { __typename: "Item", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed +it.failing( + "warns and suspends when passing parent object to `from` when key fields are missing", + async () => { + using _ = spyOnConsole("warn"); + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { replaceSnapshot, render, takeRender } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + function App() { + const result = useSuspenseFragment({ + fragment, + from: { __typename: "User" }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + "UserFields" + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + } +); + +test("returns null if `from` is `null`", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSuspenseFragment({ fragment, from: null }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); +}); + +test("returns cached value when `from` changes from `null` to non-null value", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }), + { + initialProps: { id: null as null | number }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); + } + + await rerender({ id: 1 }); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns null value when `from` changes from non-null value to `null`", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }), + { + initialProps: { id: 1 as null | number }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + await rerender({ id: null }); + + { + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("suspends until cached value is available when `from` changes from `null` to non-null value", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + function App({ id }: { id: number | null }) { + useTrackRenders(); + const result = useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ data: null }); + } + + await rerender( + }> + + + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("returns masked fragment when data masking is enabled", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toEqual({ + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("does not rerender for cache writes to masked fields", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toEqual({ + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-02-01", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("updates child fragments for cache updates to masked fields", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const postFieldsFragment: MaskedDocumentNode = gql` + fragment PostFields on Post { + updatedAt + } + `; + + const postFragment: MaskedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + ${postFieldsFragment} + `; + + client.writeFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + const { render, mergeSnapshot, takeRender } = createRenderStream({ + initialSnapshot: { + parent: null as UseSuspenseFragmentResult> | null, + child: null as UseSuspenseFragmentResult> | null, + }, + }); + + function Parent() { + useTrackRenders(); + const parent = useSuspenseFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }); + + mergeSnapshot({ parent }); + + return ; + } + + function Child({ post }: { post: FragmentType }) { + useTrackRenders(); + const child = useSuspenseFragment({ + fragment: postFieldsFragment, + from: post, + }); + + mergeSnapshot({ child }); + return null; + } + + using _disabledAct = disableActEnvironment(); + await render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toEqual({ + parent: { + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + data: { + __typename: "Post", + updatedAt: "2024-01-01", + }, + }, + }); + } + + client.writeFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-02-01", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([Child]); + expect(snapshot).toEqual({ + parent: { + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + data: { + __typename: "Post", + updatedAt: "2024-02-01", + }, + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("tears down the subscription on unmount", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ cache }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + const { unmount, takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ fragment, from: { __typename: "Item", id: 1 } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); + } + + expect(cache["watches"].size).toBe(1); + + unmount(); + // We need to wait a tick since the cleanup is run in a setTimeout to + // prevent strict mode bugs. + await wait(0); + + expect(cache["watches"].size).toBe(0); +}); + +test("tears down all watches when rendering multiple records", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ cache }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + using _disabledAct = disableActEnvironment(); + const { unmount, rerender, takeSnapshot } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ fragment, from: { __typename: "Item", id } }), + { + initialProps: { id: 1 }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); + } + + await rerender({ id: 2 }); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 2, text: "Item #2" }); + } + + unmount(); + // We need to wait a tick since the cleanup is run in a setTimeout to + // prevent strict mode bugs. + await wait(0); + + expect(cache["watches"].size).toBe(0); +}); + +test("tears down watches after default autoDisposeTimeoutMs if component never renders again after suspending", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ link, cache }); + + function App() { + const [showItem, setShowItem] = React.useState(true); + + return ( + + + {showItem && ( + + + + )} + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + // Hide the greeting before it finishes loading data + await act(() => user.click(screen.getByText("Hide item"))); + + expect(screen.queryByText("Loading item...")).not.toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + expect(cache["watches"].size).toBe(1); + + jest.advanceTimersByTime(30_000); + + expect(cache["watches"].size).toBe(0); + + jest.useRealTimers(); +}); + +test("tears down watches after configured autoDisposeTimeoutMs if component never renders again after suspending", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + link, + cache, + defaultOptions: { + react: { + suspense: { + autoDisposeTimeoutMs: 5000, + }, + }, + }, + }); + + function App() { + const [showItem, setShowItem] = React.useState(true); + + return ( + + + {showItem && ( + + + + )} + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + // Hide the greeting before it finishes loading data + await act(() => user.click(screen.getByText("Hide item"))); + + expect(screen.queryByText("Loading item...")).not.toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + expect(cache["watches"].size).toBe(1); + + jest.advanceTimersByTime(5000); + + expect(cache["watches"].size).toBe(0); + + jest.useRealTimers(); +}); + +test("cancels autoDisposeTimeoutMs if the component renders before timer finishes", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ link, cache }); + + function App() { + return ( + + + + + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + await waitFor(() => { + expect(screen.getByText("Item #1")).toBeInTheDocument(); + }); + + jest.advanceTimersByTime(30_000); + + expect(cache["watches"].size).toBe(1); + + jest.useRealTimers(); +}); + +describe.skip("type tests", () => { + test("returns TData when from is a non-null value", () => { + type Data = { foo: string }; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Query" }, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: { __typename: "Query" }, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); + + test("returns null when from is null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ fragment, from: null }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: null, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); + + test("returns TData | null when from is nullable", () => { + type Post = { __typename: "Post"; id: number }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + const author = {} as { post: Post | null }; + + { + const { data } = useSuspenseFragment({ fragment, from: author.post }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: author.post, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); + + test("variables are optional and can be anything with an untyped DocumentNode", () => { + const fragment = gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { foo: "bar" } }); + useSuspenseFragment({ fragment, from: null, variables: { bar: "baz" } }); + }); + + it("variables are optional and can be anything with unspecified TVariables on a TypedDocumentNode", () => { + const fragment: TypedDocumentNode<{ greeting: string }> = gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { foo: "bar" } }); + useSuspenseFragment({ fragment, from: null, variables: { bar: "baz" } }); + }); + + it("variables are optional and can be anything with OperationVariables on a TypedDocumentNode", () => { + const fragment: TypedDocumentNode< + { greeting: string }, + OperationVariables + > = gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { foo: "bar" } }); + useSuspenseFragment({ fragment, from: null, variables: { bar: "baz" } }); + }); + + it("variables are optional when TVariables are empty", () => { + const fragment: TypedDocumentNode< + { greeting: string }, + Record + > = gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + // @ts-expect-error unknown variable + useSuspenseFragment({ fragment, from: null, variables: { foo: "bar" } }); + }); + + it("does not allow variables when TVariables is `never`", () => { + const fragment: TypedDocumentNode<{ greeting: string }, never> = gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + // @ts-expect-error no variables argument allowed + useSuspenseFragment({ fragment, from: null, variables: { foo: "bar" } }); + }); + + it("optional variables are optional to useSuspenseFragment", () => { + const fragment: TypedDocumentNode<{ posts: string[] }, { limit?: number }> = + gql``; + + useSuspenseFragment({ fragment, from: null }); + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { limit: 10 } }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + limit: 10, + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + }); + + it("enforces required variables when TVariables includes required variables", () => { + const fragment: TypedDocumentNode<{ character: string }, { id: string }> = + gql``; + + // @ts-expect-error missing variables argument + useSuspenseFragment({ fragment, from: null }); + // @ts-expect-error empty variables + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { id: "1" } }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + id: "1", + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + }); + + it("requires variables with mixed TVariables", () => { + const fragment: TypedDocumentNode< + { character: string }, + { id: string; language?: string } + > = gql``; + + // @ts-expect-error missing variables argument + useSuspenseFragment({ fragment, from: null }); + // @ts-expect-error empty variables + useSuspenseFragment({ fragment, from: null, variables: {} }); + useSuspenseFragment({ fragment, from: null, variables: { id: "1" } }); + useSuspenseFragment({ + fragment, + from: null, + // @ts-expect-error missing required variable + variables: { language: "en" }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { id: "1", language: "en" }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + id: "1", + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + useSuspenseFragment({ + fragment, + from: null, + variables: { + id: "1", + language: "en", + // @ts-expect-error unknown variable + foo: "bar", + }, + }); + }); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index b7ba7bc04f1..c2e756ee15f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -9636,6 +9636,8 @@ describe("useSuspenseQuery", () => { expect(updateQuery).toHaveBeenCalledWith( { greeting: "Hello" }, { + complete: true, + previousData: { greeting: "Hello" }, subscriptionData: { data: { greetingUpdated: "Subscription hello" }, }, @@ -12939,12 +12941,32 @@ describe("useSuspenseQuery", () => { subscribeToMore({ document: subscription, - updateQuery: (queryData, { subscriptionData }) => { + updateQuery: ( + queryData, + { subscriptionData, complete, previousData } + ) => { expectTypeOf(queryData).toEqualTypeOf(); expectTypeOf( queryData ).not.toEqualTypeOf(); + expectTypeOf(complete).toEqualTypeOf(); + expectTypeOf(previousData).toEqualTypeOf< + | UnmaskedVariablesCaseData + | DeepPartial + | undefined + >(); + + if (complete) { + expectTypeOf( + previousData + ).toEqualTypeOf(); + } else { + expectTypeOf(previousData).toEqualTypeOf< + DeepPartial | undefined + >(); + } + expectTypeOf( subscriptionData.data ).toEqualTypeOf(); diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index 78fc82c61f4..3fe6ccbc27d 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -11,6 +11,8 @@ export type { UseSuspenseQueryResult } from "./useSuspenseQuery.js"; export { useSuspenseQuery } from "./useSuspenseQuery.js"; export type { UseBackgroundQueryResult } from "./useBackgroundQuery.js"; export { useBackgroundQuery } from "./useBackgroundQuery.js"; +export type { UseSuspenseFragmentResult } from "./useSuspenseFragment.js"; +export { useSuspenseFragment } from "./useSuspenseFragment.js"; export type { LoadQueryFunction, UseLoadableQueryResult, diff --git a/src/react/hooks/internal/wrapHook.ts b/src/react/hooks/internal/wrapHook.ts index 59b112c3216..175d3e72a5b 100644 --- a/src/react/hooks/internal/wrapHook.ts +++ b/src/react/hooks/internal/wrapHook.ts @@ -5,6 +5,7 @@ import type { useReadQuery, useFragment, useQueryRefHandlers, + useSuspenseFragment, } from "../index.js"; import type { QueryManager } from "../../../core/QueryManager.js"; import type { ApolloClient } from "../../../core/ApolloClient.js"; @@ -17,6 +18,7 @@ interface WrappableHooks { createQueryPreloader: typeof createQueryPreloader; useQuery: typeof useQuery; useSuspenseQuery: typeof useSuspenseQuery; + useSuspenseFragment: typeof useSuspenseFragment; useBackgroundQuery: typeof useBackgroundQuery; useReadQuery: typeof useReadQuery; useFragment: typeof useFragment; diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index 40480fa0a02..256d3175c90 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -6,6 +6,7 @@ import type { TypedDocumentNode, WatchQueryOptions, } from "../../core/index.js"; +import type { SubscribeToMoreFunction } from "../../core/watchQueryOptions.js"; import { useApolloClient } from "./useApolloClient.js"; import { getSuspenseCache, @@ -17,11 +18,7 @@ import type { CacheKey, QueryRef } from "../internal/index.js"; import type { BackgroundQueryHookOptions, NoInfer } from "../types/types.js"; import { wrapHook } from "./internal/index.js"; import { useWatchQueryOptions } from "./useSuspenseQuery.js"; -import type { - FetchMoreFunction, - RefetchFunction, - SubscribeToMoreFunction, -} from "./useSuspenseQuery.js"; +import type { FetchMoreFunction, RefetchFunction } from "./useSuspenseQuery.js"; import { canonicalStringify } from "../../cache/index.js"; import type { DeepPartial } from "../../utilities/index.js"; import type { SkipToken } from "./constants.js"; @@ -293,7 +290,9 @@ function useBackgroundQuery_< { fetchMore, refetch, - subscribeToMore: queryRef.observable.subscribeToMore, + // TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here + subscribeToMore: queryRef.observable + .subscribeToMore as SubscribeToMoreFunction, }, ]; } diff --git a/src/react/hooks/useLoadableQuery.ts b/src/react/hooks/useLoadableQuery.ts index b9aa70d11e2..d520b96668c 100644 --- a/src/react/hooks/useLoadableQuery.ts +++ b/src/react/hooks/useLoadableQuery.ts @@ -6,6 +6,10 @@ import type { TypedDocumentNode, WatchQueryOptions, } from "../../core/index.js"; +import type { + SubscribeToMoreFunction, + SubscribeToMoreOptions, +} from "../../core/watchQueryOptions.js"; import { useApolloClient } from "./useApolloClient.js"; import { assertWrappedQueryRef, @@ -18,11 +22,7 @@ import type { CacheKey, QueryRef } from "../internal/index.js"; import type { LoadableQueryHookOptions } from "../types/types.js"; import { __use, useRenderGuard } from "./internal/index.js"; import { useWatchQueryOptions } from "./useSuspenseQuery.js"; -import type { - FetchMoreFunction, - RefetchFunction, - SubscribeToMoreFunction, -} from "./useSuspenseQuery.js"; +import type { FetchMoreFunction, RefetchFunction } from "./useSuspenseQuery.js"; import { canonicalStringify } from "../../cache/index.js"; import type { DeepPartial, @@ -269,7 +269,10 @@ export function useLoadableQuery< "The query has not been loaded. Please load the query." ); - return internalQueryRef.observable.subscribeToMore(options); + return internalQueryRef.observable.subscribeToMore( + // TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here + options as any as SubscribeToMoreOptions + ); }, [internalQueryRef] ); diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 3c082ed0796..23e51a90b2f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -138,82 +138,87 @@ export function useMutation< return client .mutate(clientOptions as MutationOptions) - .then((response) => { - const { data, errors } = response; - const error = - errors && errors.length > 0 ? - new ApolloError({ graphQLErrors: errors }) - : void 0; - - const onError = - executeOptions.onError || ref.current.options?.onError; - - if (error && onError) { - onError( - error, - clientOptions as MutationOptions - ); - } + .then( + (response) => { + const { data, errors } = response; + const error = + errors && errors.length > 0 ? + new ApolloError({ graphQLErrors: errors }) + : void 0; + + const onError = + executeOptions.onError || ref.current.options?.onError; + + if (error && onError) { + onError( + error, + clientOptions as MutationOptions + ); + } - if ( - mutationId === ref.current.mutationId && - !clientOptions.ignoreResults - ) { - const result = { - called: true, - loading: false, - data, - error, - client, - }; - - if (ref.current.isMounted && !equal(ref.current.result, result)) { - setResult((ref.current.result = result)); + if ( + mutationId === ref.current.mutationId && + !clientOptions.ignoreResults + ) { + const result = { + called: true, + loading: false, + data, + error, + client, + }; + + if (ref.current.isMounted && !equal(ref.current.result, result)) { + setResult((ref.current.result = result)); + } } - } - const onCompleted = - executeOptions.onCompleted || ref.current.options?.onCompleted; + const onCompleted = + executeOptions.onCompleted || ref.current.options?.onCompleted; - if (!error) { - onCompleted?.( - response.data!, - clientOptions as MutationOptions - ); - } + if (!error) { + onCompleted?.( + response.data!, + clientOptions as MutationOptions + ); + } - return response; - }) - .catch((error) => { - if (mutationId === ref.current.mutationId && ref.current.isMounted) { - const result = { - loading: false, - error, - data: void 0, - called: true, - client, - }; - - if (!equal(ref.current.result, result)) { - setResult((ref.current.result = result)); + return response; + }, + (error) => { + if ( + mutationId === ref.current.mutationId && + ref.current.isMounted + ) { + const result = { + loading: false, + error, + data: void 0, + called: true, + client, + }; + + if (!equal(ref.current.result, result)) { + setResult((ref.current.result = result)); + } } - } - const onError = - executeOptions.onError || ref.current.options?.onError; + const onError = + executeOptions.onError || ref.current.options?.onError; - if (onError) { - onError( - error, - clientOptions as MutationOptions - ); + if (onError) { + onError( + error, + clientOptions as MutationOptions + ); - // TODO(brian): why are we returning this here??? - return { data: void 0, errors: error }; - } + // TODO(brian): why are we returning this here??? + return { data: void 0, errors: error }; + } - throw error; - }); + throw error; + } + ); }, [] ); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index b608c4b8b6b..764c94cb377 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -289,9 +289,10 @@ export function useQueryInternals< watchQueryOptions ); - const obsQueryFields = React.useMemo< - Omit, "variables"> - >(() => bindObservableMethods(observable), [observable]); + const obsQueryFields = React.useMemo( + () => bindObservableMethods(observable), + [observable] + ); useRegisterSSRObservable(observable, renderPromises, ssrAllowed); @@ -825,7 +826,7 @@ const skipStandbyResult = maybeDeepFreeze({ function bindObservableMethods( observable: ObservableQuery -) { +): Omit, "variables"> { return { refetch: observable.refetch.bind(observable), reobserve: observable.reobserve.bind(observable), diff --git a/src/react/hooks/useQueryRefHandlers.ts b/src/react/hooks/useQueryRefHandlers.ts index 3de9ce5631f..b4020196b24 100644 --- a/src/react/hooks/useQueryRefHandlers.ts +++ b/src/react/hooks/useQueryRefHandlers.ts @@ -8,11 +8,8 @@ import { } from "../internal/index.js"; import type { QueryRef } from "../internal/index.js"; import type { OperationVariables } from "../../core/types.js"; -import type { - RefetchFunction, - FetchMoreFunction, - SubscribeToMoreFunction, -} from "./useSuspenseQuery.js"; +import type { SubscribeToMoreFunction } from "../../core/watchQueryOptions.js"; +import type { RefetchFunction, FetchMoreFunction } from "./useSuspenseQuery.js"; import type { FetchMoreQueryOptions } from "../../core/watchQueryOptions.js"; import { useApolloClient } from "./useApolloClient.js"; import { wrapHook } from "./internal/index.js"; @@ -121,6 +118,8 @@ function useQueryRefHandlers_< return { refetch, fetchMore, - subscribeToMore: internalQueryRef.observable.subscribeToMore, + // TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here + subscribeToMore: internalQueryRef.observable + .subscribeToMore as SubscribeToMoreFunction, }; } diff --git a/src/react/hooks/useSuspenseFragment.ts b/src/react/hooks/useSuspenseFragment.ts new file mode 100644 index 00000000000..646600b8c13 --- /dev/null +++ b/src/react/hooks/useSuspenseFragment.ts @@ -0,0 +1,177 @@ +import type { + ApolloClient, + DocumentNode, + OperationVariables, + Reference, + StoreObject, + TypedDocumentNode, +} from "../../core/index.js"; +import { canonicalStringify } from "../../cache/index.js"; +import { useApolloClient } from "./useApolloClient.js"; +import { getSuspenseCache } from "../internal/index.js"; +import React, { useMemo } from "rehackt"; +import type { FragmentKey } from "../internal/cache/types.js"; +import { __use } from "./internal/__use.js"; +import { wrapHook } from "./internal/index.js"; +import type { FragmentType, MaybeMasked } from "../../masking/index.js"; +import type { NoInfer, VariablesOption } from "../types/types.js"; + +type From = + | StoreObject + | Reference + | FragmentType> + | string + | null; + +export type UseSuspenseFragmentOptions< + TData, + TVariables extends OperationVariables, +> = { + /** + * A GraphQL document created using the `gql` template string tag from + * `graphql-tag` with one or more fragments which will be used to determine + * the shape of data to read. If you provide more than one fragment in this + * document then you must also specify `fragmentName` to select a single. + */ + fragment: DocumentNode | TypedDocumentNode; + + /** + * The name of the fragment in your GraphQL document to be used. If you do + * not provide a `fragmentName` and there is only one fragment in your + * `fragment` document then that fragment will be used. + */ + fragmentName?: string; + from: From; + // Override this field to make it optional (default: true). + optimistic?: boolean; + /** + * The instance of `ApolloClient` to use to look up the fragment. + * + * By default, the instance that's passed down via context is used, but you + * can provide a different instance here. + * + * @docGroup 1. Operation options + */ + client?: ApolloClient; +} & VariablesOption>; + +export type UseSuspenseFragmentResult = { data: MaybeMasked }; + +const NULL_PLACEHOLDER = [] as unknown as [ + FragmentKey, + Promise | null>, +]; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: NonNullable>; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: null; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: From; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult { + return wrapHook( + "useSuspenseFragment", + // eslint-disable-next-line react-compiler/react-compiler + useSuspenseFragment_, + useApolloClient(typeof options === "object" ? options.client : undefined) + )(options); +} + +function useSuspenseFragment_< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult { + const client = useApolloClient(options.client); + const { from, variables } = options; + const { cache } = client; + + const id = useMemo( + () => + typeof from === "string" ? from + : from === null ? null + : cache.identify(from), + [cache, from] + ) as string | null; + + const fragmentRef = + id === null ? null : ( + getSuspenseCache(client).getFragmentRef( + [id, options.fragment, canonicalStringify(variables)], + client, + { ...options, variables: variables as TVariables, from: id } + ) + ); + + let [current, setPromise] = React.useState< + [FragmentKey, Promise | null>] + >( + fragmentRef === null ? NULL_PLACEHOLDER : ( + [fragmentRef.key, fragmentRef.promise] + ) + ); + + React.useEffect(() => { + if (fragmentRef === null) { + return; + } + + const dispose = fragmentRef.retain(); + const removeListener = fragmentRef.listen((promise) => { + setPromise([fragmentRef.key, promise]); + }); + + return () => { + dispose(); + removeListener(); + }; + }, [fragmentRef]); + + if (fragmentRef === null) { + return { data: null }; + } + + if (current[0] !== fragmentRef.key) { + // eslint-disable-next-line react-compiler/react-compiler + current[0] = fragmentRef.key; + current[1] = fragmentRef.promise; + } + + const data = __use(current[1]); + + return { data }; +} diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index faa0f44df6c..af634110fe6 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -11,6 +11,7 @@ import type { WatchQueryOptions, } from "../../core/index.js"; import { ApolloError, NetworkStatus } from "../../core/index.js"; +import type { SubscribeToMoreFunction } from "../../core/watchQueryOptions.js"; import type { DeepPartial } from "../../utilities/index.js"; import { isNonEmptyArray } from "../../utilities/index.js"; import { useApolloClient } from "./useApolloClient.js"; @@ -58,11 +59,6 @@ export type RefetchFunction< TVariables extends OperationVariables, > = ObservableQueryFields["refetch"]; -export type SubscribeToMoreFunction< - TData, - TVariables extends OperationVariables, -> = ObservableQueryFields["subscribeToMore"]; - export function useSuspenseQuery< TData, TVariables extends OperationVariables, @@ -277,7 +273,9 @@ function useSuspenseQuery_< [queryRef] ); - const subscribeToMore = queryRef.observable.subscribeToMore; + // TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here + const subscribeToMore = queryRef.observable + .subscribeToMore as SubscribeToMoreFunction; return React.useMemo< UseSuspenseQueryResult diff --git a/src/react/internal/cache/FragmentReference.ts b/src/react/internal/cache/FragmentReference.ts new file mode 100644 index 00000000000..85453892bcc --- /dev/null +++ b/src/react/internal/cache/FragmentReference.ts @@ -0,0 +1,200 @@ +import { equal } from "@wry/equality"; +import type { + WatchFragmentOptions, + WatchFragmentResult, +} from "../../../cache/index.js"; +import type { ApolloClient } from "../../../core/ApolloClient.js"; +import type { MaybeMasked } from "../../../masking/index.js"; +import { + createFulfilledPromise, + wrapPromiseWithState, +} from "../../../utilities/index.js"; +import type { + Observable, + ObservableSubscription, + PromiseWithState, +} from "../../../utilities/index.js"; +import type { FragmentKey } from "./types.js"; + +type FragmentRefPromise = PromiseWithState; +type Listener = (promise: FragmentRefPromise) => void; + +interface FragmentReferenceOptions { + autoDisposeTimeoutMs?: number; + onDispose?: () => void; +} + +export class FragmentReference< + TData = unknown, + TVariables = Record, +> { + public readonly observable: Observable>; + public readonly key: FragmentKey = {}; + public promise!: FragmentRefPromise>; + + private resolve: ((result: MaybeMasked) => void) | undefined; + private reject: ((error: unknown) => void) | undefined; + + private subscription!: ObservableSubscription; + private listeners = new Set>>(); + private autoDisposeTimeoutId?: NodeJS.Timeout; + + private references = 0; + + constructor( + client: ApolloClient, + watchFragmentOptions: WatchFragmentOptions & { + from: string; + }, + options: FragmentReferenceOptions + ) { + this.dispose = this.dispose.bind(this); + this.handleNext = this.handleNext.bind(this); + this.handleError = this.handleError.bind(this); + + this.observable = client.watchFragment(watchFragmentOptions); + + if (options.onDispose) { + this.onDispose = options.onDispose; + } + + const diff = this.getDiff(client, watchFragmentOptions); + + // Start a timer that will automatically dispose of the query if the + // suspended resource does not use this fragmentRef in the given time. This + // helps prevent memory leaks when a component has unmounted before the + // query has finished loading. + const startDisposeTimer = () => { + if (!this.references) { + this.autoDisposeTimeoutId = setTimeout( + this.dispose, + options.autoDisposeTimeoutMs ?? 30_000 + ); + } + }; + + this.promise = + diff.complete ? + createFulfilledPromise(diff.result) + : this.createPendingPromise(); + this.subscribeToFragment(); + + this.promise.then(startDisposeTimer, startDisposeTimer); + } + + listen(listener: Listener>) { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + retain() { + this.references++; + clearTimeout(this.autoDisposeTimeoutId); + let disposed = false; + + return () => { + if (disposed) { + return; + } + + disposed = true; + this.references--; + + setTimeout(() => { + if (!this.references) { + this.dispose(); + } + }); + }; + } + + private dispose() { + this.subscription.unsubscribe(); + this.onDispose(); + } + + private onDispose() { + // noop. overridable by options + } + + private subscribeToFragment() { + this.subscription = this.observable.subscribe( + this.handleNext.bind(this), + this.handleError.bind(this) + ); + } + + private handleNext(result: WatchFragmentResult) { + switch (this.promise.status) { + case "pending": { + if (result.complete) { + return this.resolve?.(result.data); + } + + this.deliver(this.promise); + break; + } + case "fulfilled": { + // This can occur when we already have a result written to the cache and + // we subscribe for the first time. We create a fulfilled promise in the + // constructor with a value that is the same as the first emitted value + // so we want to skip delivering it. + if (equal(this.promise.value, result.data)) { + return; + } + + this.promise = + result.complete ? + createFulfilledPromise(result.data) + : this.createPendingPromise(); + + this.deliver(this.promise); + } + } + } + + private handleError(error: unknown) { + this.reject?.(error); + } + + private deliver(promise: FragmentRefPromise>) { + this.listeners.forEach((listener) => listener(promise)); + } + + private createPendingPromise() { + return wrapPromiseWithState( + new Promise>((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }) + ); + } + + private getDiff( + client: ApolloClient, + options: WatchFragmentOptions & { from: string } + ) { + const { cache } = client; + const { from, fragment, fragmentName } = options; + + const diff = cache.diff({ + ...options, + query: cache["getFragmentDoc"](fragment, fragmentName), + returnPartialData: true, + id: from, + optimistic: true, + }); + + return { + ...diff, + result: client["queryManager"].maskFragment({ + fragment, + fragmentName, + data: diff.result, + }) as MaybeMasked, + }; + } +} diff --git a/src/react/internal/cache/SuspenseCache.ts b/src/react/internal/cache/SuspenseCache.ts index 8b1eba321b5..03bf0c43049 100644 --- a/src/react/internal/cache/SuspenseCache.ts +++ b/src/react/internal/cache/SuspenseCache.ts @@ -1,8 +1,13 @@ import { Trie } from "@wry/trie"; -import type { ObservableQuery } from "../../../core/index.js"; +import type { + ApolloClient, + ObservableQuery, + WatchFragmentOptions, +} from "../../../core/index.js"; import { canUseWeakMap } from "../../../utilities/index.js"; import { InternalQueryReference } from "./QueryReference.js"; -import type { CacheKey } from "./types.js"; +import type { CacheKey, FragmentCacheKey } from "./types.js"; +import { FragmentReference } from "./FragmentReference.js"; export interface SuspenseCacheOptions { /** @@ -22,6 +27,10 @@ export class SuspenseCache { private queryRefs = new Trie<{ current?: InternalQueryReference }>( canUseWeakMap ); + private fragmentRefs = new Trie<{ current?: FragmentReference }>( + canUseWeakMap + ); + private options: SuspenseCacheOptions; constructor(options: SuspenseCacheOptions = Object.create(null)) { @@ -48,6 +57,27 @@ export class SuspenseCache { return ref.current; } + getFragmentRef( + cacheKey: FragmentCacheKey, + client: ApolloClient, + options: WatchFragmentOptions & { from: string } + ) { + const ref = this.fragmentRefs.lookupArray(cacheKey) as { + current?: FragmentReference; + }; + + if (!ref.current) { + ref.current = new FragmentReference(client, options, { + autoDisposeTimeoutMs: this.options.autoDisposeTimeoutMs, + onDispose: () => { + delete ref.current; + }, + }); + } + + return ref.current; + } + add(cacheKey: CacheKey, queryRef: InternalQueryReference) { const ref = this.queryRefs.lookupArray(cacheKey); ref.current = queryRef; diff --git a/src/react/internal/cache/types.ts b/src/react/internal/cache/types.ts index 40f3c4cc8fc..a163431ad9d 100644 --- a/src/react/internal/cache/types.ts +++ b/src/react/internal/cache/types.ts @@ -6,6 +6,16 @@ export type CacheKey = [ ...queryKey: any[], ]; +export type FragmentCacheKey = [ + cacheId: string, + fragment: DocumentNode, + stringifiedVariables: string, +]; + export interface QueryKey { __queryKey?: string; } + +export interface FragmentKey { + __fragmentKey?: string; +} diff --git a/src/react/query-preloader/createQueryPreloader.ts b/src/react/query-preloader/createQueryPreloader.ts index 6389992519c..50b9e500579 100644 --- a/src/react/query-preloader/createQueryPreloader.ts +++ b/src/react/query-preloader/createQueryPreloader.ts @@ -15,25 +15,9 @@ import type { } from "../../utilities/index.js"; import { InternalQueryReference, wrapQueryRef } from "../internal/index.js"; import type { PreloadedQueryRef } from "../internal/index.js"; -import type { NoInfer } from "../index.js"; +import type { NoInfer, VariablesOption } from "../index.js"; import { wrapHook } from "../hooks/internal/index.js"; -type VariablesOption = - [TVariables] extends [never] ? - { - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ - variables?: Record; - } - : {} extends OnlyRequiredProperties ? - { - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ - variables?: TVariables; - } - : { - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ - variables: TVariables; - }; - export type PreloadQueryFetchPolicy = Extract< WatchQueryFetchPolicy, "cache-first" | "network-only" | "no-cache" | "cache-and-network" diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 7812fb34bc2..5e82f4dc8fc 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -5,6 +5,7 @@ import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; import type { Observable, ObservableSubscription, + OnlyRequiredProperties, } from "../../utilities/index.js"; import type { FetchResult } from "../../link/core/index.js"; import type { ApolloError } from "../../errors/index.js"; @@ -19,7 +20,6 @@ import type { InternalRefetchQueriesInclude, WatchQueryOptions, WatchQueryFetchPolicy, - SubscribeToMoreOptions, ApolloQueryResult, FetchMoreQueryOptions, ErrorPolicy, @@ -28,6 +28,8 @@ import type { import type { MutationSharedOptions, SharedWatchQueryOptions, + SubscribeToMoreFunction, + UpdateQueryMapFn, } from "../../core/watchQueryOptions.js"; import type { MaybeMasked, Unmasked } from "../../masking/index.js"; @@ -67,9 +69,19 @@ export interface QueryFunctionOptions< > extends BaseQueryOptions { /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#skip:member} */ skip?: boolean; - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#onCompleted:member} */ + /** + * {@inheritDoc @apollo/client!QueryOptionsDocumentation#onCompleted:member} + * + * @deprecated This option will be removed in the next major version of Apollo Client. + * For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. + */ onCompleted?: (data: MaybeMasked) => void; - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#onError:member} */ + /** + * {@inheritDoc @apollo/client!QueryOptionsDocumentation#onError:member} + * + * @deprecated This option will be removed in the next major version of Apollo Client. + * For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. + */ onError?: (error: ApolloError) => void; // Default WatchQueryOptions for this useQuery, providing initial values for @@ -90,23 +102,9 @@ export interface ObservableQueryFields< /** {@inheritDoc @apollo/client!QueryResultDocumentation#stopPolling:member} */ stopPolling: () => void; /** {@inheritDoc @apollo/client!QueryResultDocumentation#subscribeToMore:member} */ - subscribeToMore: < - TSubscriptionData = TData, - TSubscriptionVariables extends OperationVariables = TVariables, - >( - options: SubscribeToMoreOptions< - TData, - TSubscriptionVariables, - TSubscriptionData - > - ) => () => void; + subscribeToMore: SubscribeToMoreFunction; /** {@inheritDoc @apollo/client!QueryResultDocumentation#updateQuery:member} */ - updateQuery: ( - mapFn: ( - previousQueryResult: Unmasked, - options: Pick, "variables"> - ) => Unmasked - ) => void; + updateQuery: (mapFn: UpdateQueryMapFn) => void; /** {@inheritDoc @apollo/client!QueryResultDocumentation#refetch:member} */ refetch: ( variables?: Partial @@ -180,9 +178,19 @@ export interface LazyQueryHookOptions< TData = any, TVariables extends OperationVariables = OperationVariables, > extends BaseQueryOptions { - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#onCompleted:member} */ + /** + * {@inheritDoc @apollo/client!QueryOptionsDocumentation#onCompleted:member} + * + * @deprecated This option will be removed in the next major version of Apollo Client. + * For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. + */ onCompleted?: (data: MaybeMasked) => void; - /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#onError:member} */ + /** + * {@inheritDoc @apollo/client!QueryOptionsDocumentation#onError:member} + * + * @deprecated This option will be removed in the next major version of Apollo Client. + * For more context, please see the [related issue](https://github.com/apollographql/apollo-client/issues/12352) on GitHub. + */ onError?: (error: ApolloError) => void; /** @internal */ @@ -358,7 +366,12 @@ export interface BaseMutationOptions< ) => void; /** {@inheritDoc @apollo/client!MutationOptionsDocumentation#onError:member} */ onError?: (error: ApolloError, clientOptions?: BaseMutationOptions) => void; - /** {@inheritDoc @apollo/client!MutationOptionsDocumentation#ignoreResults:member} */ + /** + * {@inheritDoc @apollo/client!MutationOptionsDocumentation#ignoreResults:member} + * + * @deprecated This option will be removed in the next major version of Apollo Client. + * If you don't want to synchronize your component state with the mutation, please use `useApolloClient` to get your ApolloClient instance and call `client.mutate` directly. + */ ignoreResults?: boolean; } @@ -515,4 +528,20 @@ export interface SubscriptionCurrentObservable { subscription?: ObservableSubscription; } +export type VariablesOption = + [TVariables] extends [never] ? + { + /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ + variables?: Record; + } + : Record extends OnlyRequiredProperties ? + { + /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ + variables?: TVariables; + } + : { + /** {@inheritDoc @apollo/client!QueryOptionsDocumentation#variables:member} */ + variables: TVariables; + }; + export type { NoInfer } from "../../utilities/index.js";