diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 0e7551ac5dc..cd57659d197 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -20,10 +20,12 @@ import { getApolloCacheMemoryInternals } from '@apollo/client/utilities/internal import type { GetDataState } from '@apollo/client'; import { getInMemoryCacheMemoryInternals } from '@apollo/client/utilities/internal'; import type { InlineFragmentNode } from 'graphql'; +import type { IsAny } from '@apollo/client/utilities/internal'; import { isReference } from '@apollo/client/utilities'; import type { NoInfer as NoInfer_2 } from '@apollo/client/utilities/internal'; import { Observable } from 'rxjs'; import type { OperationVariables } from '@apollo/client'; +import type { Prettify } from '@apollo/client/utilities/internal'; import { Reference } from '@apollo/client/utilities'; import type { SelectionSetNode } from 'graphql'; import type { StoreObject } from '@apollo/client/utilities'; @@ -39,20 +41,36 @@ type AllFieldsModifier> = Modifier extends Observable> { + getCurrentResult: () => ApolloCache.WatchFragmentResult; + } + export type WatchFragmentFromValue = StoreObject | Reference | FragmentType> | string | null; export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; - from: StoreObject | Reference | FragmentType> | string; + from: ApolloCache.WatchFragmentFromValue | Array>; optimistic?: boolean; variables?: TVariables; } - export type WatchFragmentResult = ({ + export type WatchFragmentResult = true extends IsAny ? ({ + complete: true; + missing?: never; + } & GetDataState) | ({ + complete: false; + missing?: MissingTree; + } & GetDataState) : TData extends null | null[] ? Prettify<{ + complete: true; + missing?: never; + } & GetDataState> : Prettify<{ complete: true; missing?: never; - } & GetDataState) | ({ + } & GetDataState> | { complete: false; - missing: MissingTree; - } & GetDataState); + missing?: MissingTree; + data: TData extends Array ? Array | null> : DataValue.Partial; + dataState: "partial"; + }; } // @public (undocumented) @@ -77,6 +95,7 @@ export abstract class ApolloCache { lookupFragment(fragmentName: string): FragmentDefinitionNode | null; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; + protected onAfterBroadcast: (cb: () => void) => void; // (undocumented) abstract performTransaction(transaction: Transaction, optimisticId?: string | null): void; // (undocumented) @@ -94,6 +113,7 @@ export abstract class ApolloCache { abstract removeOptimistic(id: string): void; // (undocumented) abstract reset(options?: Cache_2.ResetOptions): Promise; + resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; @@ -105,7 +125,28 @@ export abstract class ApolloCache { updateQuery(options: Cache_2.UpdateQueryOptions, update: (data: Unmasked | null) => Unmasked | null | void): Unmasked | null; // (undocumented) abstract watch(watch: Cache_2.WatchOptions): () => void; - watchFragment(options: ApolloCache.WatchFragmentOptions): Observable>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>>; + }): ApolloCache.ObservableFragment>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>; + }): ApolloCache.ObservableFragment | null>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: null; + }): ApolloCache.ObservableFragment; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: NonNullable>; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions): ApolloCache.ObservableFragment | null>; // (undocumented) abstract write(write: Cache_2.WriteOptions): Reference | undefined; writeFragment({ data, fragment, fragmentName, variables, overwrite, id, broadcast, }: Cache_2.WriteFragmentOptions): Reference | undefined; @@ -544,6 +585,8 @@ export class InMemoryCache extends ApolloCache { // (undocumented) reset(options?: Cache_2.ResetOptions): Promise; // (undocumented) + resolvesClientField(typename: string, fieldName: string): boolean; + // (undocumented) restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index c1f15c36f34..50b1aca96b3 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -195,6 +195,13 @@ export namespace ApolloClient { } } // (undocumented) + export interface Experiment { + // (undocumented) + (this: ApolloClient, options: ApolloClient.Options): void; + // (undocumented) + v: 1; + } + // (undocumented) export type MutateOptions = { optimisticResponse?: Unmasked> | ((vars: TVariables, { IGNORE }: { IGNORE: IgnoreModifier; @@ -217,6 +224,10 @@ export namespace ApolloClient { extensions?: Record; } // (undocumented) + export interface ObservableFragment extends Observable_2> { + getCurrentResult: () => ApolloClient.WatchFragmentResult; + } + // (undocumented) export interface Options { assumeImmutableResults?: boolean; cache: ApolloCache; @@ -231,6 +242,7 @@ export namespace ApolloClient { documentTransform?: DocumentTransform; // (undocumented) enhancedClientAwareness?: ClientAwarenessLink.EnhancedClientAwarenessOptions; + experiments?: ApolloClient.Experiment[]; incrementalHandler?: Incremental.Handler; link: ApolloLink; // (undocumented) @@ -287,7 +299,7 @@ export namespace ApolloClient { // (undocumented) export type WatchFragmentOptions = ApolloCache.WatchFragmentOptions; // (undocumented) - export type WatchFragmentResult = ApolloCache.WatchFragmentResult; + export type WatchFragmentResult = ApolloCache.WatchFragmentResult>; export type WatchQueryOptions = { fetchPolicy?: WatchQueryFetchPolicy; nextFetchPolicy?: WatchQueryFetchPolicy | ((this: WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); @@ -359,7 +371,23 @@ export class ApolloClient { subscribe(options: ApolloClient.SubscribeOptions): SubscriptionObservable>>; // (undocumented) version: string; - watchFragment(options: ApolloClient.WatchFragmentOptions): Observable_2>>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array; + }): ApolloClient.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: null; + }): ApolloClient.ObservableFragment; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: NonNullable>; + }): ApolloClient.ObservableFragment; + watchFragment(options: ApolloClient.WatchFragmentOptions): ApolloClient.ObservableFragment; watchQuery(options: ApolloClient.WatchQueryOptions): ObservableQuery; writeFragment(options: ApolloClient.WriteFragmentOptions): Reference_2 | undefined; writeQuery(options: ApolloClient.WriteQueryOptions): Reference_2 | undefined; @@ -1132,8 +1160,8 @@ export type WatchQueryOptions>; } // (undocumented) - type IncrementalDeferPayload> = { - data?: TData | null | undefined; + type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; // (undocumented) + type IncrementalResult> = IncrementalDeferResult | IncrementalStreamResult; + // (undocumented) + type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + // (undocumented) type InitialResult> = { data?: TData | null | undefined; errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; // (undocumented) type SubsequentResult> = { - data?: TData | null | undefined; - errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; // (undocumented) interface TypeOverrides { @@ -80,6 +89,102 @@ class DeferRequest> implements Incremental hasNext: boolean; } +// @public (undocumented) +export namespace GraphQL17Alpha9Handler { + // (undocumented) + export type Chunk = InitialResult | SubsequentResult; + // (undocumented) + export interface CompletedResult { + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + id: string; + } + // (undocumented) + export interface GraphQL17Alpha9Result extends HKT { + // (undocumented) + arg1: unknown; + // (undocumented) + arg2: unknown; + // (undocumented) + return: GraphQL17Alpha9Handler.Chunk>; + } + // (undocumented) + export interface IncrementalDeferResult> { + // (undocumented) + data: TData; + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + extensions?: Record; + // (undocumented) + id: string; + // (undocumented) + subPath?: Incremental.Path; + } + // (undocumented) + export type IncrementalResult = IncrementalDeferResult | IncrementalStreamResult; + // (undocumented) + export interface IncrementalStreamResult> { + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + extensions?: Record; + // (undocumented) + id: string; + // (undocumented) + items: TData; + // (undocumented) + subPath?: Incremental.Path; + } + // (undocumented) + export type InitialResult> = { + data: TData; + errors?: ReadonlyArray; + pending: ReadonlyArray; + hasNext: boolean; + extensions?: Record; + }; + // (undocumented) + export interface PendingResult { + // (undocumented) + id: string; + // (undocumented) + label?: string; + // (undocumented) + path: Incremental.Path; + } + // (undocumented) + export type SubsequentResult = { + hasNext: boolean; + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + extensions?: Record; + }; + // (undocumented) + export interface TypeOverrides { + // (undocumented) + AdditionalApolloLinkResultTypes: GraphQL17Alpha9Result; + } +} + +// @public +export class GraphQL17Alpha9Handler implements Incremental.Handler> { + // @internal @deprecated (undocumented) + extractErrors(result: ApolloLink.Result): GraphQLFormattedError[] | undefined; + // @internal @deprecated (undocumented) + isIncrementalResult(result: ApolloLink.Result): result is GraphQL17Alpha9Handler.InitialResult | GraphQL17Alpha9Handler.SubsequentResult; + // @internal @deprecated (undocumented) + prepareRequest(request: ApolloLink.Request): ApolloLink.Request; + // Warning: (ae-forgotten-export) The symbol "IncrementalRequest" needs to be exported by the entry point index.d.ts + // + // @internal @deprecated (undocumented) + startRequest(_: { + query: DocumentNode; + }): IncrementalRequest; +} + // @public (undocumented) export namespace Incremental { // @internal @deprecated (undocumented) @@ -106,6 +211,14 @@ export namespace Incremental { export type Path = ReadonlyArray; } +// @public (undocumented) +class IncrementalRequest implements Incremental.IncrementalRequest, TData> { + // (undocumented) + handle(cacheData: TData | DeepPartial | null | undefined, chunk: GraphQL17Alpha9Handler.Chunk): FormattedExecutionResult; + // (undocumented) + hasNext: boolean; +} + // @public (undocumented) export namespace NotImplementedHandler { // (undocumented) diff --git a/.api-reports/api-report-local-state.api.md b/.api-reports/api-report-local-state.api.md index 747567da303..4a633e1a00f 100644 --- a/.api-reports/api-report-local-state.api.md +++ b/.api-reports/api-report-local-state.api.md @@ -14,6 +14,7 @@ import type { NoInfer as NoInfer_2 } from '@apollo/client/utilities/internal'; import type { OperationVariables } from '@apollo/client'; import type { RemoveIndexSignature } from '@apollo/client/utilities/internal'; import type { TypedDocumentNode } from '@apollo/client'; +import type { WatchQueryFetchPolicy } from '@apollo/client'; // @public (undocumented) type InferContextValueFromResolvers = TResolvers extends { @@ -91,7 +92,7 @@ export class LocalState({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: { + execute({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; context: DefaultContext | undefined; @@ -99,6 +100,7 @@ export class LocalState>; // (undocumented) getExportedVariables({ document, client, context, variables, }: { diff --git a/.api-reports/api-report-masking.api.md b/.api-reports/api-report-masking.api.md index 21efc419692..3f17b4419b0 100644 --- a/.api-reports/api-report-masking.api.md +++ b/.api-reports/api-report-masking.api.md @@ -7,6 +7,7 @@ import type { ApolloCache } from '@apollo/client'; import type { ApplyHKTImplementationWithDefault } from '@apollo/client/utilities/internal'; import type { DocumentNode } from '@apollo/client'; +import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core'; import type { HKT } from '@apollo/client/utilities'; import type { IsAny } from '@apollo/client/utilities/internal'; import type { Prettify } from '@apollo/client/utilities/internal'; @@ -63,7 +64,7 @@ type ExtractByMatchingTypeNames = ApplyHKTImplementationWithDefault; +export type FragmentType = ApplyHKTImplementationWithDefault) ? TFragmentData : TFragmentDataOrTypedDocumentNode>; // @public (undocumented) export namespace GraphQLCodegenDataMasking { diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 213a6882526..a42c4284f70 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -5,6 +5,7 @@ ```ts import type { ApolloCache } from '@apollo/client'; +import type { ApolloCache as ApolloCache_2 } from '@apollo/client/cache'; import type { ApolloClient } from '@apollo/client'; import type { DataState } from '@apollo/client'; import type { DataValue } from '@apollo/client'; @@ -16,7 +17,6 @@ import type { ErrorLike } from '@apollo/client'; import type { ErrorPolicy } from '@apollo/client'; import type { FetchMoreFunction } from '@apollo/client/react/internal'; import type { FetchPolicy } from '@apollo/client'; -import type { FragmentType } from '@apollo/client/masking'; import type { GetDataState } from '@apollo/client'; import type { HookWrappers } from '@apollo/client/react/internal'; import type { IgnoreModifier } from '@apollo/client/cache'; @@ -41,12 +41,8 @@ import { QueryRef } from '@apollo/client/react/internal'; import type { QueryRef as QueryRef_2 } from '@apollo/client/react'; import type { ReactiveVar } from '@apollo/client'; import type * as ReactTypes from 'react'; -import type { Reference } from '@apollo/client/cache'; -import type { Reference as Reference_2 } from '@apollo/client'; import type { RefetchFunction } from '@apollo/client/react/internal'; import type { RefetchWritePolicy } from '@apollo/client'; -import type { StoreObject } from '@apollo/client/cache'; -import type { StoreObject as StoreObject_2 } from '@apollo/client'; import type { SubscribeToMoreFunction } from '@apollo/client'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import type { TypedDocumentNode as TypedDocumentNode_2 } from '@apollo/client'; @@ -87,9 +83,6 @@ export type BackgroundQueryHookOptions = StoreObject_2 | Reference_2 | FragmentType> | string | null; - // @public (undocumented) export function getApolloContext(): ReactTypes.Context; @@ -388,6 +381,21 @@ export namespace useBackgroundQuery { // @public @deprecated (undocumented) export type UseBackgroundQueryResult = useBackgroundQuery.Result; +// @public +export function useFragment(options: useFragment.Options & { + from: Array>>; +}): useFragment.Result>; + +// @public +export function useFragment(options: useFragment.Options & { + from: Array; +}): useFragment.Result>; + +// @public +export function useFragment(options: useFragment.Options & { + from: Array>; +}): useFragment.Result>; + // @public export function useFragment(options: useFragment.Options): useFragment.Result; @@ -421,12 +429,13 @@ export namespace useFragment { export namespace DocumentationTypes { export function useFragment({ fragment, from, fragmentName, variables, optimistic, client, }: useFragment.Options): useFragment.Result; } + export type FromValue = ApolloCache_2.WatchFragmentFromValue; // (undocumented) export interface Options { client?: ApolloClient; fragment: DocumentNode_2 | TypedDocumentNode_2; fragmentName?: string; - from: StoreObject | Reference | FragmentType> | string | null; + from: useFragment.FromValue | Array>; optimistic?: boolean; variables?: NoInfer_2; } @@ -434,10 +443,12 @@ export namespace useFragment { export type Result = ({ complete: true; missing?: never; - } & GetDataState, "complete">) | ({ + } & GetDataState, "complete">) | { complete: false; missing?: MissingTree; - } & GetDataState, "partial">); + data: TData extends Array ? Array | null> : DataValue.Partial; + dataState: "partial"; + }; } // @public @deprecated (undocumented) @@ -639,7 +650,9 @@ export namespace useMutation { } ]) => Promise>>; // (undocumented) - export type MutationFunctionOptions = Options; + export type MutationFunctionOptions = Options & { + context?: DefaultContext | ((hookContext: DefaultContext | undefined) => DefaultContext); + }; // (undocumented) export interface Options = Partial> { awaitRefetchQueries?: boolean; @@ -910,9 +923,24 @@ export namespace useSubscription { } } +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array>>; +}): useSuspenseFragment.Result>; + +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array; +}): useSuspenseFragment.Result>; + +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array>; +}): useSuspenseFragment.Result>; + // @public export function useSuspenseFragment(options: useSuspenseFragment.Options & { - from: NonNullable>; + from: NonNullable>; }): useSuspenseFragment.Result; // @public @@ -922,7 +950,7 @@ export function useSuspenseFragment(options: useSuspenseFragment.Options & { - from: From; + from: useSuspenseFragment.FromValue; }): useSuspenseFragment.Result; // @public @@ -937,7 +965,7 @@ export namespace useSuspenseFragment { export type Options = { fragment: DocumentNode_2 | TypedDocumentNode_2; fragmentName?: string; - from: From; + from: useSuspenseFragment.FromValue | Array>; optimistic?: boolean; client?: ApolloClient; }; @@ -964,6 +992,7 @@ export namespace useSuspenseFragment { export namespace DocumentationTypes { export function useSuspenseFragment(options: useSuspenseFragment.Options): useSuspenseFragment.Result; } + export type FromValue = ApolloCache_2.WatchFragmentFromValue; // (undocumented) export type Options = Base.Options & VariablesOption>; // (undocumented) @@ -1086,10 +1115,6 @@ export namespace useSuspenseQuery { // @public @deprecated (undocumented) export type UseSuspenseQueryResult = useSuspenseQuery.Result; -// Warnings were encountered during analysis: -// -// src/react/hooks/useSuspenseFragment.ts:111: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/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index a8652491621..ea1bc92d70f 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -8,12 +8,14 @@ import type { ApolloClient } from '@apollo/client'; import type { DataState } from '@apollo/client'; import type { DecoratedPromise } from '@apollo/client/utilities/internal'; import type { DocumentNode } from 'graphql'; +import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core'; import type { InternalTypes } from '@apollo/client/react'; import type { MaybeMasked } from '@apollo/client/masking'; import type { MaybeMasked as MaybeMasked_2 } from '@apollo/client'; -import type { Observable } from 'rxjs'; import type { ObservableQuery } from '@apollo/client'; import type { OperationVariables } from '@apollo/client'; +import type { ResultOf } from '@graphql-typed-document-node/core'; +import type { VariablesOf } from '@graphql-typed-document-node/core'; // Warning: (ae-forgotten-export) The symbol "WrappedQueryRef" needs to be exported by the entry point index.d.ts // @@ -35,9 +37,9 @@ export type FetchMoreFunction = { // Warning: (ae-forgotten-export) The symbol "FragmentReferenceOptions" needs to be exported by the entry point index.d.ts constructor(client: ApolloClient, watchFragmentOptions: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | null | Array; }, options: FragmentReferenceOptions); // (undocumented) readonly key: FragmentKey; @@ -59,7 +61,7 @@ class FragmentReference>): () => void; // (undocumented) - readonly observable: Observable>; + readonly observable: ApolloClient.ObservableFragment; // Warning: (ae-forgotten-export) The symbol "FragmentRefPromise" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -187,6 +189,12 @@ export interface QueryRef, TStates extends DataState>["dataState"] = "complete" | "streaming"> = QueryRef, VariablesOf, TStates>; +} + // @public (undocumented) type QueryRefPromise["dataState"]> = DecoratedPromise, TStates>>; @@ -203,7 +211,7 @@ class SuspenseCache { // // (undocumented) getFragmentRef(cacheKey: FragmentCacheKey, client: ApolloClient, options: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | null | Array; }): FragmentReference; // (undocumented) getQueryRef["dataState"] = DataState["dataState"]>(cacheKey: CacheKey, createObservable: () => ObservableQuery): InternalQueryReference; diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 7042954b4a1..df5116df8d9 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -66,7 +66,7 @@ export function concatPagination(keyArgs?: KeyArgs): FieldPolic // Warning: (ae-forgotten-export) The symbol "DeepPartialObject" needs to be exported by the entry point index.d.ts // // @public -export type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; +export type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; // @public (undocumented) type DeepPartialMap = {} & Map, DeepPartial>; diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index f4329853077..109cfc7d8c9 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -18,7 +18,7 @@ import type { HKT } from '@apollo/client/utilities'; import type { InlineFragmentNode } from 'graphql'; import type { MaybeMasked } from '@apollo/client'; import type { NetworkStatus } from '@apollo/client'; -import type { Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import type { ObservableQuery } from '@apollo/client'; import type { Observer } from 'rxjs'; import type { OperationDefinitionNode } from 'graphql'; @@ -74,6 +74,11 @@ export const checkDocument: (doc: DocumentNode, expectedType?: OperationTypeNode // @internal @deprecated export function cloneDeep(value: T): T; +// @public +export function combineLatestBatched(observables: Array & { + dirty?: boolean; +}>): Observable; + // Warning: (ae-forgotten-export) The symbol "TupleToIntersection" needs to be exported by the entry point index.d.ts // // @internal @deprecated @@ -100,9 +105,20 @@ export type DecoratedPromise = PendingPromise | FulfilledPromise export function decoratePromise(promise: Promise): DecoratedPromise; // @internal @deprecated (undocumented) -export class DeepMerger { +export namespace DeepMerger { + // (undocumented) + export type ArrayMergeStrategy = "truncate" | "combine"; + // (undocumented) + export interface Options { + // (undocumented) + arrayMerge?: DeepMerger.ArrayMergeStrategy; + } +} + +// @internal @deprecated (undocumented) +export class DeepMerger { // Warning: (ae-forgotten-export) The symbol "ReconcilerFunction" needs to be exported by the entry point index.d.ts - constructor(reconciler?: ReconcilerFunction); + constructor(reconciler?: ReconcilerFunction, options?: DeepMerger.Options); // (undocumented) isObject: typeof isNonNullObject; // (undocumented) @@ -449,7 +465,7 @@ export type VariablesOption = {} extends // Warnings were encountered during analysis: // -// src/utilities/internal/getStoreKeyName.ts:88:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts +// src/utilities/internal/getStoreKeyName.ts:89:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index fa731a1b8b6..c2b2ac60c06 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -8,6 +8,7 @@ import type { ASTNode } from 'graphql'; import { disableExperimentalFragmentVariables } from 'graphql-tag'; import { disableFragmentWarnings } from 'graphql-tag'; import { DocumentNode } from 'graphql'; +import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core'; import { enableExperimentalFragmentVariables } from 'graphql-tag'; import type { FieldNode } from 'graphql'; import type { FormattedExecutionResult } from 'graphql'; @@ -37,21 +38,39 @@ type AllFieldsModifier> = Modifier extends Observable> { + getCurrentResult: () => ApolloCache.WatchFragmentResult; + } + // Warning: (ae-forgotten-export) The symbol "NoInfer_2" needs to be exported by the entry point index.d.ts + export type WatchFragmentFromValue = StoreObject | Reference | FragmentType> | string | null; export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; - // Warning: (ae-forgotten-export) The symbol "NoInfer_2" needs to be exported by the entry point index.d.ts - from: StoreObject | Reference | FragmentType> | string; + from: ApolloCache.WatchFragmentFromValue | Array>; optimistic?: boolean; variables?: TVariables; } - export type WatchFragmentResult = ({ + // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Prettify" needs to be exported by the entry point index.d.ts + export type WatchFragmentResult = true extends IsAny ? ({ + complete: true; + missing?: never; + } & GetDataState) | ({ + complete: false; + missing?: MissingTree; + } & GetDataState) : TData extends null | null[] ? Prettify<{ + complete: true; + missing?: never; + } & GetDataState> : Prettify<{ complete: true; missing?: never; - } & GetDataState) | ({ + } & GetDataState> | { complete: false; - missing: MissingTree; - } & GetDataState); + missing?: MissingTree; + data: TData extends Array ? Array | null> : DataValue.Partial; + dataState: "partial"; + }; } // @public (undocumented) @@ -78,6 +97,7 @@ export abstract class ApolloCache { lookupFragment(fragmentName: string): FragmentDefinitionNode | null; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; + protected onAfterBroadcast: (cb: () => void) => void; // (undocumented) abstract performTransaction(transaction: Transaction, optimisticId?: string | null): void; // (undocumented) @@ -95,6 +115,7 @@ export abstract class ApolloCache { abstract removeOptimistic(id: string): void; // (undocumented) abstract reset(options?: Cache_2.ResetOptions): Promise; + resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; @@ -106,7 +127,28 @@ export abstract class ApolloCache { updateQuery(options: Cache_2.UpdateQueryOptions, update: (data: Unmasked | null) => Unmasked | null | void): Unmasked | null; // (undocumented) abstract watch(watch: Cache_2.WatchOptions): () => void; - watchFragment(options: ApolloCache.WatchFragmentOptions): Observable>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>>; + }): ApolloCache.ObservableFragment>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>; + }): ApolloCache.ObservableFragment | null>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: null; + }): ApolloCache.ObservableFragment; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: NonNullable>; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions): ApolloCache.ObservableFragment | null>; // (undocumented) abstract write(write: Cache_2.WriteOptions): Reference | undefined; writeFragment({ data, fragment, fragmentName, variables, overwrite, id, broadcast, }: Cache_2.WriteFragmentOptions): Reference | undefined; @@ -201,6 +243,13 @@ export namespace ApolloClient { variables?: TVariables; } } + // (undocumented) + export interface Experiment { + // (undocumented) + (this: ApolloClient, options: ApolloClient.Options): void; + // (undocumented) + v: 1; + } // Warning: (ae-forgotten-export) The symbol "VariablesOption" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -226,6 +275,10 @@ export namespace ApolloClient { extensions?: Record; } // (undocumented) + export interface ObservableFragment extends Observable> { + getCurrentResult: () => ApolloClient.WatchFragmentResult; + } + // (undocumented) export interface Options { assumeImmutableResults?: boolean; cache: ApolloCache; @@ -242,6 +295,7 @@ export namespace ApolloClient { documentTransform?: DocumentTransform; // (undocumented) enhancedClientAwareness?: ClientAwarenessLink.EnhancedClientAwarenessOptions; + experiments?: ApolloClient.Experiment[]; // Warning: (ae-forgotten-export) The symbol "Incremental" needs to be exported by the entry point index.d.ts incrementalHandler?: Incremental.Handler; link: ApolloLink; @@ -301,7 +355,7 @@ export namespace ApolloClient { // (undocumented) export type WatchFragmentOptions = ApolloCache.WatchFragmentOptions; // (undocumented) - export type WatchFragmentResult = ApolloCache.WatchFragmentResult; + export type WatchFragmentResult = ApolloCache.WatchFragmentResult>; export type WatchQueryOptions = { fetchPolicy?: WatchQueryFetchPolicy; nextFetchPolicy?: WatchQueryFetchPolicy | ((this: WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); @@ -374,7 +428,23 @@ export class ApolloClient { subscribe(options: ApolloClient.SubscribeOptions): SubscriptionObservable>>; // (undocumented) version: string; - watchFragment(options: ApolloClient.WatchFragmentOptions): Observable>>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array; + }): ApolloClient.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: null; + }): ApolloClient.ObservableFragment; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: NonNullable>; + }): ApolloClient.ObservableFragment; + watchFragment(options: ApolloClient.WatchFragmentOptions): ApolloClient.ObservableFragment; watchQuery(options: ApolloClient.WatchQueryOptions): ObservableQuery; writeFragment(options: ApolloClient.WriteFragmentOptions): Reference | undefined; writeQuery(options: ApolloClient.WriteQueryOptions): Reference | undefined; @@ -838,7 +908,7 @@ export namespace DataValue { // Warning: (ae-forgotten-export) The symbol "DeepPartialObject" needs to be exported by the entry point index.d.ts // // @public -type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; +type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; // Warning: (ae-forgotten-export) The symbol "DeepPartial" needs to be exported by the entry point index.d.ts // @@ -1147,7 +1217,7 @@ interface FragmentRegistryAPI { // Warning: (ae-forgotten-export) The symbol "PreserveTypes" needs to be exported by the entry point index.d.ts // // @public -export type FragmentType = ApplyHKTImplementationWithDefault; +export type FragmentType = ApplyHKTImplementationWithDefault) ? TFragmentData : TFragmentDataOrTypedDocumentNode>; // @public @deprecated (undocumented) export const from: typeof ApolloLink.from; @@ -1373,6 +1443,8 @@ export class InMemoryCache extends ApolloCache { // (undocumented) reset(options?: Cache_2.ResetOptions): Promise; // (undocumented) + resolvesClientField(typename: string, fieldName: string): boolean; + // (undocumented) restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; @@ -1569,7 +1641,7 @@ class LocalState({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: { + execute({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; context: DefaultContext | undefined; @@ -1577,6 +1649,7 @@ class LocalState>; // (undocumented) getExportedVariables({ document, client, context, variables, }: { @@ -2137,6 +2210,11 @@ namespace PreserveTypes { type Unmasked = TData; } +// @internal @deprecated (undocumented) +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + // @internal @deprecated (undocumented) type Primitive = null | undefined | string | number | boolean | symbol | bigint; @@ -2360,8 +2438,6 @@ type RefetchQueriesIncludeShorthand = "all" | "active"; // @public @deprecated (undocumented) export type RefetchQueriesOptions = ApolloClient.RefetchQueriesOptions; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts -// // @public (undocumented) export type RefetchQueriesPromiseResults = IsAny extends true ? any[] : TResult extends boolean ? ApolloClient.QueryResult[] : TResult extends PromiseLike ? U[] : TResult[]; @@ -2706,18 +2782,18 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/core/cache.ts:94:9 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts +// src/cache/core/cache.ts:123:11 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:98:3 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:134:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/core/ApolloClient.ts:159:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts -// src/core/ApolloClient.ts:353:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:361:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ApolloClient.ts:168:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts +// src/core/ApolloClient.ts:370:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:368:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:180:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:147:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:200:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:243:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:149:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:202:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:245:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/big-flowers-move.md b/.changeset/big-flowers-move.md new file mode 100644 index 00000000000..c80ffc762f5 --- /dev/null +++ b/.changeset/big-flowers-move.md @@ -0,0 +1,23 @@ +--- +"@apollo/client": minor +--- + +You can now provide a callback function as the `context` option on the `mutate` function returned by `useMutation`. The callback function is called with the value of the `context` option provided to the `useMutation` hook. This is useful if you'd like to merge the context object provided to the `useMutation` hook with a value provided to the `mutate` function. + + +```ts +function MyComponent() { + const [mutate, result] = useMutation(MUTATION, { + context: { foo: true } + }); + + async function runMutation() { + await mutate({ + // sends context as { foo: true, bar: true } + context: (hookContext) => ({ ...hookContext, bar: true }) + }); + } + + // ... +} +``` diff --git a/.changeset/cold-kiwis-give.md b/.changeset/cold-kiwis-give.md new file mode 100644 index 00000000000..880998840fe --- /dev/null +++ b/.changeset/cold-kiwis-give.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Fix an issue where deferred payloads that reteurned arrays with fewer items than the original cached array would retain items from the cached array. This change includes `@stream` arrays where stream arrays replace the cached arrays. diff --git a/.changeset/famous-hats-explode.md b/.changeset/famous-hats-explode.md new file mode 100644 index 00000000000..6a924e646d2 --- /dev/null +++ b/.changeset/famous-hats-explode.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Deduplicate watches created by `useFragment`, `client.watchFragment`, and `cache.watchFragment` that contain the same fragment, variables, and identifier. This should improve performance in situations where a `useFragment` or a `client.watchFragment` is used to watch the same object in multiple places of an application. diff --git a/.changeset/few-parrots-raise.md b/.changeset/few-parrots-raise.md new file mode 100644 index 00000000000..3a3980400a3 --- /dev/null +++ b/.changeset/few-parrots-raise.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Allow `FragmentType` not only to be called as `FragmentType`, but also as `FragmentType`. diff --git a/.changeset/flat-worms-notice.md b/.changeset/flat-worms-notice.md new file mode 100644 index 00000000000..6833bc32800 --- /dev/null +++ b/.changeset/flat-worms-notice.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Don't set the fallback value of a `@client` field to `null` when a `read` function is defined. Instead the `read` function will be called with an `existing` value of `undefined` to allow default arguments to be used to set the returned value. + +When a `read` function is not defined nor is there a defined resolver for the field, warn and set the value to `null` only in that instance. diff --git a/.changeset/funny-bats-hammer.md b/.changeset/funny-bats-hammer.md new file mode 100644 index 00000000000..9848f45763b --- /dev/null +++ b/.changeset/funny-bats-hammer.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where calling `fetchMore` with `@defer` or `@stream` would not rerender incremental results as they were streamed. diff --git a/.changeset/large-ligers-prove.md b/.changeset/large-ligers-prove.md new file mode 100644 index 00000000000..dcc9d9d9e1d --- /dev/null +++ b/.changeset/large-ligers-prove.md @@ -0,0 +1,13 @@ +--- +"@apollo/client": minor +--- + +Add support for `from: null` in `client.watchFragment` and `cache.watchFragment`. When `from` is `null`, the emitted result is: + +```ts +{ + data: null, + dataState: "complete", + complete: true, +} +``` diff --git a/.changeset/little-yaks-decide.md b/.changeset/little-yaks-decide.md new file mode 100644 index 00000000000..53aa1d9cd75 --- /dev/null +++ b/.changeset/little-yaks-decide.md @@ -0,0 +1,17 @@ +--- +"@apollo/client": minor +--- + +Support the newer incremental delivery format for the `@defer` directive implemented in `graphql@17.0.0-alpha.9`. Import the `GraphQL17Alpha9Handler` to use the newer incremental delivery format with `@defer`. + +```ts +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; + +const client = new ApolloClient({ + // ... + incrementalHandler: new GraphQL17Alpha9Handler(), +}); +``` + +> [!NOTE] +> In order to use the `GraphQL17Alpha9Handler`, the GraphQL server MUST implement the newer incremental delivery format. You may see errors or unusual behavior if you use the wrong handler. If you are using Apollo Router, continue to use the `Defer20220824Handler` because Apollo Router does not yet support the newer incremental delivery format. diff --git a/.changeset/neat-lemons-shave.md b/.changeset/neat-lemons-shave.md new file mode 100644 index 00000000000..d7357691800 --- /dev/null +++ b/.changeset/neat-lemons-shave.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Improve the cache data loss warning message when `existing` or `incoming` is an array. diff --git a/.changeset/neat-windows-compete.md b/.changeset/neat-windows-compete.md new file mode 100644 index 00000000000..ae595909bad --- /dev/null +++ b/.changeset/neat-windows-compete.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ignore top-level `data` values on subsequent chunks in incremental responses. diff --git a/.changeset/old-singers-eat.md b/.changeset/old-singers-eat.md new file mode 100644 index 00000000000..84c8ffa53e3 --- /dev/null +++ b/.changeset/old-singers-eat.md @@ -0,0 +1,17 @@ +--- +"@apollo/client": minor +--- + +Add support for arrays with `useFragment`, `useSuspenseFragment`, and `client.watchFragment`. This allows the ability to use a fragment to watch multiple entities in the cache. Passing an array to `from` will return `data` as an array where each array index corresponds to the index in the `from` array. + +```ts +function MyComponent() { + const result = useFragment({ + fragment, + from: [item1, item2, item3] + }); + + // `data` is an array with 3 items + console.log(result); // { data: [{...}, {...}, {...}], dataState: "complete", complete: true } +} +``` diff --git a/.changeset/olive-queens-fold.md b/.changeset/olive-queens-fold.md new file mode 100644 index 00000000000..ea3abc75a0c --- /dev/null +++ b/.changeset/olive-queens-fold.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Create mechanism to add experimental features to Apollo Client diff --git a/.changeset/perfect-crabs-smile.md b/.changeset/perfect-crabs-smile.md new file mode 100644 index 00000000000..d85e58e885f --- /dev/null +++ b/.changeset/perfect-crabs-smile.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure `LocalState` doesn't try to read from the cache when using a `no-cache` fetch policy. diff --git a/.changeset/poor-knives-smile.md b/.changeset/poor-knives-smile.md new file mode 100644 index 00000000000..9294c18f6b9 --- /dev/null +++ b/.changeset/poor-knives-smile.md @@ -0,0 +1,19 @@ +--- +"@apollo/client": minor +--- + +Add a `getCurrentResult` function to the observable returned by `client.watchFragment` and `cache.watchFragment` that returns the current value for the watched fragment. + +```ts +const observable = client.watchFragment({ + fragment, + from: { __typename: 'Item', id: 1 } +}) + +console.log(observable.getCurrentResult()); +// { +// data: {...}, +// dataState: "complete", +// complete: true, +// } +``` diff --git a/.changeset/popular-files-glow.md b/.changeset/popular-files-glow.md new file mode 100644 index 00000000000..53955edd598 --- /dev/null +++ b/.changeset/popular-files-glow.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure an error is thrown when `@stream` is detected and an `incrementalDelivery` handler is not configured. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000000..2eab7992cad --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,31 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@apollo/client": "4.0.4", + "@apollo/client-graphql-codegen": "1.0.0", + "@apollo/client-codemod-migrate-3-to-4": "1.0.2" + }, + "changesets": [ + "big-flowers-move", + "cold-kiwis-give", + "famous-hats-explode", + "flat-worms-notice", + "funny-bats-hammer", + "large-ligers-prove", + "little-yaks-decide", + "neat-lemons-shave", + "neat-windows-compete", + "old-singers-eat", + "olive-queens-fold", + "perfect-crabs-smile", + "poor-knives-smile", + "popular-files-glow", + "shaggy-brooms-talk", + "shaggy-islands-yell", + "six-islands-drum", + "slimy-ducks-scream", + "spicy-eels-switch", + "unlucky-cooks-rhyme" + ] +} diff --git a/.changeset/rare-pigs-play.md b/.changeset/rare-pigs-play.md new file mode 100644 index 00000000000..8fa84335a93 --- /dev/null +++ b/.changeset/rare-pigs-play.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Add helper type `QueryRef.ForQuery` diff --git a/.changeset/shaggy-brooms-talk.md b/.changeset/shaggy-brooms-talk.md new file mode 100644 index 00000000000..0ec18d995e1 --- /dev/null +++ b/.changeset/shaggy-brooms-talk.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix the `Defer20220824Handler.SubsequentResult` type to match the `FormattedSubsequentIncrementalExecutionResult` type in `graphql@17.0.0-alpha.2`. diff --git a/.changeset/shaggy-islands-yell.md b/.changeset/shaggy-islands-yell.md new file mode 100644 index 00000000000..0056f773493 --- /dev/null +++ b/.changeset/shaggy-islands-yell.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Warn when using a `no-cache` fetch policy without a local resolver defined. `no-cache` queries do not read or write to the cache which meant `no-cache` queries are silently incomplete when the `@client` field value was handled by a cache `read` function. diff --git a/.changeset/six-islands-drum.md b/.changeset/six-islands-drum.md new file mode 100644 index 00000000000..e540e2b375c --- /dev/null +++ b/.changeset/six-islands-drum.md @@ -0,0 +1,8 @@ +--- +"@apollo/client": minor +--- + +Add support for the `@stream` directive on both the `Defer20220824Handler` and the `GraphQL17Alpha2Handler`. + +> [!NOTE] +> The implementations of `@stream` differ in the delivery of incremental results between the different GraphQL spec versions. If you upgrading from the older format to the newer format, expect the timing of some incremental results to change. diff --git a/.changeset/slimy-ducks-scream.md b/.changeset/slimy-ducks-scream.md new file mode 100644 index 00000000000..66187335a80 --- /dev/null +++ b/.changeset/slimy-ducks-scream.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Update the `accept` header used with the `GraphQL17Alpha9Handler` to `multipart/mixed;incrementalSpec=v0.2` to ensure the newest incremental delivery format is requested. diff --git a/.changeset/spicy-eels-switch.md b/.changeset/spicy-eels-switch.md new file mode 100644 index 00000000000..b65d6362a85 --- /dev/null +++ b/.changeset/spicy-eels-switch.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +`DeepPartial>` now returns `Array>` instead of `Array>`. diff --git a/.changeset/unlucky-cooks-rhyme.md b/.changeset/unlucky-cooks-rhyme.md new file mode 100644 index 00000000000..c73a23c930c --- /dev/null +++ b/.changeset/unlucky-cooks-rhyme.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Add an abstract `resolvesClientField` function to `ApolloCache` that can be used by caches to tell `LocalState` if it can resolve a `@client` field when a local resolver is not defined. + +`LocalState` will emit a warning and set a fallback value of `null` when no local resolver is defined and `resolvesClientField` returns `false`, or isn't defined. Returning `true` from `resolvesClientField` signals that a mechanism in the cache will set the field value. In this case, `LocalState` won't set the field value. diff --git a/.prettierrc b/.prettierrc index 8a0e9b37b39..5e21b9169ee 100644 --- a/.prettierrc +++ b/.prettierrc @@ -17,6 +17,12 @@ "parser": "typescript-with-jsdoc" } }, + { + "files": ["**/__tests__/**/*.ts", "**/__tests__/**/*.tsx"], + "options": { + "parser": "typescript" + } + }, { "files": ["*.mdx"], "options": { diff --git a/.size-limits.json b/.size-limits.json index 7f303c892bf..40377491633 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43857, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38699, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33415, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27498 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 45545, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 40136, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 34567, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 28389 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0711d36e851..59301a4c344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,139 @@ # @apollo/client +## 4.1.0-alpha.3 + +### Minor Changes + +- [#12971](https://github.com/apollographql/apollo-client/pull/12971) [`d11eb40`](https://github.com/apollographql/apollo-client/commit/d11eb40aa41d90ac664705bac01158d58bf55e9b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add support for `from: null` in `client.watchFragment` and `cache.watchFragment`. When `from` is `null`, the emitted result is: + + ```ts + { + data: null, + dataState: "complete", + complete: true, + } + ``` + +- [#12971](https://github.com/apollographql/apollo-client/pull/12971) [`d11eb40`](https://github.com/apollographql/apollo-client/commit/d11eb40aa41d90ac664705bac01158d58bf55e9b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add support for arrays with `useFragment`, `useSuspenseFragment`, and `client.watchFragment`. This allows the ability to use a fragment to watch multiple entities in the cache. Passing an array to `from` will return `data` as an array where each array index corresponds to the index in the `from` array. + + ```ts + function MyComponent() { + const result = useFragment({ + fragment, + from: [item1, item2, item3], + }); + + // `data` is an array with 3 items + console.log(result); // { data: [{...}, {...}, {...}], dataState: "complete", complete: true } + } + ``` + +- [#12971](https://github.com/apollographql/apollo-client/pull/12971) [`d11eb40`](https://github.com/apollographql/apollo-client/commit/d11eb40aa41d90ac664705bac01158d58bf55e9b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add a `getCurrentResult` function to the observable returned by `client.watchFragment` and `cache.watchFragment` that returns the current value for the watched fragment. + + ```ts + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + console.log(observable.getCurrentResult()); + // { + // data: {...}, + // dataState: "complete", + // complete: true, + // } + ``` + +### Patch Changes + +- [#12971](https://github.com/apollographql/apollo-client/pull/12971) [`d11eb40`](https://github.com/apollographql/apollo-client/commit/d11eb40aa41d90ac664705bac01158d58bf55e9b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Deduplicate watches created by `useFragment`, `client.watchFragment`, and `cache.watchFragment` that contain the same fragment, variables, and identifier. This should improve performance in situations where a `useFragment` or a `client.watchFragment` is used to watch the same object in multiple places of an application. + +- [#12982](https://github.com/apollographql/apollo-client/pull/12982) [`5c56b32`](https://github.com/apollographql/apollo-client/commit/5c56b3210a2c03e247ec9e600f1e27eb71df5e96) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ignore top-level `data` values on subsequent chunks in incremental responses. + +- [#12982](https://github.com/apollographql/apollo-client/pull/12982) [`5c56b32`](https://github.com/apollographql/apollo-client/commit/5c56b3210a2c03e247ec9e600f1e27eb71df5e96) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix the `Defer20220824Handler.SubsequentResult` type to match the `FormattedSubsequentIncrementalExecutionResult` type in `graphql@17.0.0-alpha.2`. + +- [#12973](https://github.com/apollographql/apollo-client/pull/12973) [`072da24`](https://github.com/apollographql/apollo-client/commit/072da24a8daec3a646ef0cce30de32f95ea0bb23) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Update the `accept` header used with the `GraphQL17Alpha9Handler` to `multipart/mixed;incrementalSpec=v0.2` to ensure the newest incremental delivery format is requested. + +- [#12971](https://github.com/apollographql/apollo-client/pull/12971) [`d11eb40`](https://github.com/apollographql/apollo-client/commit/d11eb40aa41d90ac664705bac01158d58bf55e9b) Thanks [@jerelmiller](https://github.com/jerelmiller)! - `DeepPartial>` now returns `Array>` instead of `Array>`. + +## 4.1.0-alpha.2 + +### Minor Changes + +- [#12959](https://github.com/apollographql/apollo-client/pull/12959) [`556e837`](https://github.com/apollographql/apollo-client/commit/556e83781069d925a7e8f99e49023f6f858c6438) Thanks [@jerelmiller](https://github.com/jerelmiller)! - You can now provide a callback function as the `context` option on the `mutate` function returned by `useMutation`. The callback function is called with the value of the `context` option provided to the `useMutation` hook. This is useful if you'd like to merge the context object provided to the `useMutation` hook with a value provided to the `mutate` function. + + ```ts + function MyComponent() { + const [mutate, result] = useMutation(MUTATION, { + context: { foo: true }, + }); + + async function runMutation() { + await mutate({ + // sends context as { foo: true, bar: true } + context: (hookContext) => ({ ...hookContext, bar: true }), + }); + } + + // ... + } + ``` + +### Patch Changes + +- [#12954](https://github.com/apollographql/apollo-client/pull/12954) [`1c82eaf`](https://github.com/apollographql/apollo-client/commit/1c82eafe4921a9e30128202623be6c5a3d4df803) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure an error is thrown when `@stream` is detected and an `incrementalDelivery` handler is not configured. + +## 4.1.0-alpha.1 + +### Minor Changes + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Don't set the fallback value of a `@client` field to `null` when a `read` function is defined. Instead the `read` function will be called with an `existing` value of `undefined` to allow default arguments to be used to set the returned value. + + When a `read` function is not defined nor is there a defined resolver for the field, warn and set the value to `null` only in that instance. + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add an abstract `resolvesClientField` function to `ApolloCache` that can be used by caches to tell `LocalState` if it can resolve a `@client` field when a local resolver is not defined. + + `LocalState` will emit a warning and set a fallback value of `null` when no local resolver is defined and `resolvesClientField` returns `false`, or isn't defined. Returning `true` from `resolvesClientField` signals that a mechanism in the cache will set the field value. In this case, `LocalState` won't set the field value. + +### Patch Changes + +- [#12915](https://github.com/apollographql/apollo-client/pull/12915) [`c97b145`](https://github.com/apollographql/apollo-client/commit/c97b145188d39d754ff098ff399a80cae5b10cc0) Thanks [@phryneas](https://github.com/phryneas)! - Create mechanism to add experimental features to Apollo Client + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure `LocalState` doesn't try to read from the cache when using a `no-cache` fetch policy. + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Warn when using a `no-cache` fetch policy without a local resolver defined. `no-cache` queries do not read or write to the cache which meant `no-cache` queries are silently incomplete when the `@client` field value was handled by a cache `read` function. + +## 4.1.0-alpha.0 + +### Minor Changes + +- [#12923](https://github.com/apollographql/apollo-client/pull/12923) [`2aa31c7`](https://github.com/apollographql/apollo-client/commit/2aa31c718155e88814551afb14fd7a0035acc57d) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix an issue where deferred payloads that reteurned arrays with fewer items than the original cached array would retain items from the cached array. This change includes `@stream` arrays where stream arrays replace the cached arrays. + +- [#12926](https://github.com/apollographql/apollo-client/pull/12926) [`c7fba99`](https://github.com/apollographql/apollo-client/commit/c7fba99e16da522fdbc35b9c16cdb8df0dda4c2c) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Support the newer incremental delivery format for the `@defer` directive implemented in `graphql@17.0.0-alpha.9`. Import the `GraphQL17Alpha9Handler` to use the newer incremental delivery format with `@defer`. + + ```ts + import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; + + const client = new ApolloClient({ + // ... + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + ``` + + > [!NOTE] + > In order to use the `GraphQL17Alpha9Handler`, the GraphQL server MUST implement the newer incremental delivery format. You may see errors or unusual behavior if you use the wrong handler. If you are using Apollo Router, continue to use the `Defer20220824Handler` because Apollo Router does not yet support the newer incremental delivery format. + +- [#12918](https://github.com/apollographql/apollo-client/pull/12918) [`562e219`](https://github.com/apollographql/apollo-client/commit/562e2191a4b38e05edb3da9074e2958db3c7b6b9) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add support for the `@stream` directive on both the `Defer20220824Handler` and the `GraphQL17Alpha2Handler`. + + > [!NOTE] + > The implementations of `@stream` differ in the delivery of incremental results between the different GraphQL spec versions. If you upgrading from the older format to the newer format, expect the timing of some incremental results to change. + +### Patch Changes + +- [#12925](https://github.com/apollographql/apollo-client/pull/12925) [`f538a83`](https://github.com/apollographql/apollo-client/commit/f538a83621e1d110286c056dd8e91611dfd9a1d3) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix an issue where calling `fetchMore` with `@defer` or `@stream` would not rerender incremental results as they were streamed. + +- [#12923](https://github.com/apollographql/apollo-client/pull/12923) [`01cace0`](https://github.com/apollographql/apollo-client/commit/01cace0a6d4faf79e8a4188b93c7d13c4b26d6d4) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Improve the cache data loss warning message when `existing` or `incoming` is an array. + ## 4.0.4 ### Patch Changes diff --git a/config/jest.config.ts b/config/jest.config.ts index 9e8be6190dc..8fded0c2cd4 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -48,9 +48,13 @@ const react17TestFileIgnoreList = [ // React 17 "src/testing/experimental/__tests__/createTestSchema.test.tsx", "src/react/hooks/__tests__/useSuspenseFragment.test.tsx", + "src/react/hooks/__tests__/useSuspenseFragment/*", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", + "src/react/hooks/__tests__/useSuspenseQuery/*", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", + "src/react/hooks/__tests__/useBackgroundQuery/*", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", + "src/react/hooks/__tests__/useLoadableQuery/*", "src/react/hooks/__tests__/useQueryRefHandlers.test.tsx", "src/react/query-preloader/__tests__/createQueryPreloader.test.tsx", "src/react/ssr/__tests__/prerenderStatic.test.tsx", diff --git a/docs/source/data/fragments.mdx b/docs/source/data/fragments.mdx index 8a58f2959cf..7f40c6518da 100644 --- a/docs/source/data/fragments.mdx +++ b/docs/source/data/fragments.mdx @@ -518,7 +518,7 @@ function List() { } ``` - + Instead of interpolating fragments within each query document, you can use Apollo Client's `createFragmentRegistry` method to pre-register named fragments with `InMemoryCache`. This allows Apollo Client to include the @@ -526,7 +526,7 @@ function List() { before the request is sent. For more information, see [Registering named fragments using `createFragmentRegistry`](#registering-named-fragments-using-createfragmentregistry). - + We can then use `useFragment` from within the `` component to create a live binding for each item by providing the `fragment` document, `fragmentName` and object reference via `from`. @@ -564,10 +564,10 @@ function Item(props) { - + You may omit the `fragmentName` option when your fragment definition only includes a single fragment. - + You may instead prefer to pass the whole `item` as a prop to the `Item` component. This makes the `from` option more concise. @@ -576,7 +576,7 @@ You may instead prefer to pass the whole `item` as a prop to the `Item` componen ```tsx function Item(props: { item: { __typename: "Item"; id: number } }) { const { complete, data } = useFragment({ - fragment: ItemFragment, + fragment: ITEM_FRAGMENT, fragmentName: "ItemFragment", from: props.item, }); @@ -608,6 +608,114 @@ function Item(props) { See the [API reference](../api/react/useFragment) for more details on the supported options. + + +### Working with arrays + + + +Sometimes your component might use a fragment to select fields for an array of items that are received from props. You can use the `useFragment` hook to watch for changes on each array item by providing the array to the `from` option. + +When you provide an array to the `from` option, the `data` property returned from `useFragment` is an array where each item corresponds to an item with the same index in the `from` option. If all of the items returned in `data` are complete, the `complete` property is set to `true` and the `dataState` property is set to `"complete"`. If at least one item in the array is incomplete, the `complete` property is set to `false` and the `dataState` property is set to `"partial"`. + + + +```tsx +function Items(props: { items: Array<{ __typename: "Item"; id: number }> }) { + const { data, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: props.items, + }); + + if (!complete) { + return null; + } + + return ( +
    + {data.map((item) => ( +
  • {item.text}
  • + ))} +
+ ); +} +``` + +```js +function Items(props) { + const { data, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: props.items, + }); + + if (!complete) { + return null; + } + + return ( +
    + {data.map((item) => ( +
  • {item.text}
  • + ))} +
+ ); +} +``` + +
+ + + +If the array provided to the `from` option is an empty array, the returned `data` is an empty array with the `complete` property set to `true` and `dataState` property set to `"complete"`. + + + +#### Handling `null` values + +Depending on the GraphQL schema, it's possible the array might contain `null` values. When `useFragment` is provided an array that contains `null` values to the `from` property, `useFragment` returns those items as `null` in the `data` property and treats these items as complete. This means if all non-`null` items in the array are also complete, the whole result is complete. + +```ts +const { data, dataState, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: [{ __typename: "Item", id: 1 }, { __typename: "Item", id: 2 }, null], +}); + +console.log({ data, dataState, complete }); +// { +// data: [ +// { __typename: "Item", id: 1, text: "..." }, +// { __typename: "Item", id: 2, text: "..." }, +// null +// ] +// dataState: "complete", +// complete: true +// } +``` + + + +If the `from` array contains `null` values for every item, the result returned from `useFragment` contains all `null` values, the `complete` property is set to `true`, and the `dataState` property is set to `"complete"`. + +```ts +const { data, dataState, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: [null, null, null], +}); + +console.log({ data, dataState, complete }); +// { +// data: [null, null, null], +// dataState: "complete", +// complete: true +// } +``` + + + ## `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. diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index f12a780c3b2..15fe52b449e 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -156,6 +156,31 @@ When using TypeScript, you might see an error related to a missing variable when + + +##### Merging `context` from the hook and `mutate` function + + + +Due to option precedence, `context` provided to the `mutate` function overrides `context` provided to the `useMutation` hook. In some cases, you might want to merge the `context` value provided to the hook with a value available at the time you execute the `mutate` function. + +You accomplish this by using a callback function for the `context` option provided to the `mutate` function. The callback function is called with the `context` value provided to the hook, allowing you to merge them together. + +```ts +addTodo({ + context: (hookContext) => ({ + ...hookContext, + myCustomValue: true, + }), +}); +``` + + + +Your callback function is not required to merge the context values together. The `context` value sent to the link chain is the value returned from the function which makes it possible to change the `context` value in any way you wish, such as omitting a property from the hook context. + + + ### Tracking mutation status In addition to a mutate function, the `useMutation` hook returns an object that represents the current state of the mutation's execution. The fields of this object include booleans that indicate whether the mutate function has been `called` and whether the mutation's result is currently `loading`. diff --git a/eslint.config.mjs b/eslint.config.mjs index d2ed43cccaa..3c93d4a9c48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,15 +15,15 @@ if (!process.features.typescript) { import path from "node:path"; import { fileURLToPath } from "node:url"; -import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import { fixupPluginRules } from "@eslint/compat"; import { FlatCompat } from "@eslint/eslintrc"; import js from "@eslint/js"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import tsParser from "@typescript-eslint/parser"; import _import from "eslint-plugin-import"; import * as mdx from "eslint-plugin-mdx"; -import reactCompiler from "eslint-plugin-react-compiler"; import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; import localRules from "./eslint-local-rules/index.mjs"; @@ -143,19 +143,16 @@ export default [ ], }, }, - ...fixupConfigRules(compat.extends("plugin:react-hooks/recommended")).map( - (config) => ({ - ...config, - files: ["**/*.ts", "**/*.tsx"], - ignores: ["**/__tests__/**/*.*", "**/*.d.ts"], - }) - ), + { + ...reactHooks.configs.flat.recommended, + files: ["src/react/**/*.ts", "src/react/**/*.tsx"], + ignores: ["**/__tests__/**/*.*", "**/*.d.ts"], + }, { files: ["**/*.ts", "**/*.tsx"], ignores: ["**/__tests__/**/*.*", "**/*.d.ts"], plugins: { - "react-compiler": reactCompiler, ...tsPlugins, }, @@ -178,7 +175,6 @@ export default [ // rules for source files, but no tests rules: { - "react-compiler/react-compiler": "error", "@typescript-eslint/consistent-type-exports": ["error"], "@typescript-eslint/no-import-type-side-effects": "error", "@typescript-eslint/no-restricted-types": [ diff --git a/knip.config.js b/knip.config.js index edecf0d3204..42b4feb18dd 100644 --- a/knip.config.js +++ b/knip.config.js @@ -47,6 +47,8 @@ const config = { "src/config/jest/resolver.ts", "config/listImports.ts", "scripts/codemods/**/__testfixtures__/**/*", + // Exports `KeyOptions` used in `matchers/index.d.ts`, but can't pick it up + "src/testing/matchers/toHaveFragmentWatchesOn.ts", ], ignoreBinaries: ["jq"], ignoreDependencies: [ diff --git a/package-lock.json b/package-lock.json index bd60c4e9976..89356997cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -67,14 +67,13 @@ "@typescript-eslint/types": "8.21.0", "@typescript-eslint/utils": "8.21.0", "ast-types": "0.16.1", - "babel-plugin-react-compiler": "19.1.0-rc.2", + "babel-plugin-react-compiler": "1.0.0", "eslint": "9.31.0", "eslint-import-resolver-typescript": "3.7.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-local-rules": "3.0.2", "eslint-plugin-mdx": "^3.6.2", - "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-react-hooks": "5.1.0", + "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-testing-library": "7.1.1", "expect": "29.7.0", "expect-type": "1.1.0", @@ -82,6 +81,7 @@ "globals": "15.14.0", "graphql": "16.9.0", "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", + "graphql-17-alpha9": "npm:graphql@17.0.0-alpha.9", "graphql-ws": "6.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", @@ -983,21 +983,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-proposal-private-methods": { - "version": "7.18.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "dev": true, @@ -7863,9 +7848,9 @@ } }, "node_modules/babel-plugin-react-compiler": { - "version": "19.1.0-rc.2", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.2.tgz", - "integrity": "sha512-kSNA//p5fMO6ypG8EkEVPIqAjwIXm5tMjfD1XRPL/sRjYSbJ6UsvORfaeolNWnZ9n310aM0xJP7peW26BuCVzA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "dev": true, "license": "MIT", "dependencies": { @@ -10186,36 +10171,47 @@ "eslint": ">=8.0.0" } }, - "node_modules/eslint-plugin-react-compiler": { - "version": "19.1.0-rc.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz", - "integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==", + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", - "@babel/plugin-proposal-private-methods": "^7.18.6", "hermes-parser": "^0.25.1", - "zod": "^3.22.4", - "zod-validation-error": "^3.0.3" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + "node": ">=18" }, "peerDependencies": { - "eslint": ">=7" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0", + "node_modules/eslint-plugin-react-hooks/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18.0.0" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/eslint-plugin-testing-library": { @@ -11498,6 +11494,17 @@ "node": "^14.19.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/graphql-17-alpha9": { + "name": "graphql", + "version": "17.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.0-alpha.9.tgz", + "integrity": "sha512-jVK1BsvX5pUIEpRDlEgeKJr80GAxl3B8ISsFDjXHtl2xAxMXVGTEFF4Q4R8NH0Gw7yMwcHDndkNjoNT5CbwHKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.19.0 || ^18.14.0 || >=19.7.0" + } + }, "node_modules/graphql-config": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.5.tgz", diff --git a/package.json b/package.json index 71218fb900e..d76c34cfef4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.3", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ @@ -199,14 +199,13 @@ "@typescript-eslint/types": "8.21.0", "@typescript-eslint/utils": "8.21.0", "ast-types": "0.16.1", - "babel-plugin-react-compiler": "19.1.0-rc.2", + "babel-plugin-react-compiler": "1.0.0", "eslint": "9.31.0", "eslint-import-resolver-typescript": "3.7.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-local-rules": "3.0.2", "eslint-plugin-mdx": "^3.6.2", - "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-react-hooks": "5.1.0", + "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-testing-library": "7.1.1", "expect": "29.7.0", "expect-type": "1.1.0", @@ -214,6 +213,7 @@ "globals": "15.14.0", "graphql": "16.9.0", "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", + "graphql-17-alpha9": "npm:graphql@17.0.0-alpha.9", "graphql-ws": "6.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", diff --git a/patches/graphql-17-alpha9+17.0.0-alpha.9.patch b/patches/graphql-17-alpha9+17.0.0-alpha.9.patch new file mode 100644 index 00000000000..591af1a11f4 --- /dev/null +++ b/patches/graphql-17-alpha9+17.0.0-alpha.9.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/graphql-17-alpha9/execution/types.d.ts b/node_modules/graphql-17-alpha9/execution/types.d.ts +index 48ef2e9..6ef2ab3 100644 +--- a/node_modules/graphql-17-alpha9/execution/types.d.ts ++++ b/node_modules/graphql-17-alpha9/execution/types.d.ts +@@ -95,9 +95,8 @@ export interface CompletedResult { + errors?: ReadonlyArray; + } + export interface FormattedCompletedResult { +- path: ReadonlyArray; +- label?: string; +- errors?: ReadonlyArray; ++ id: string; ++ errors?: ReadonlyArray; + } + export declare function isPendingExecutionGroup(incrementalDataRecord: IncrementalDataRecord): incrementalDataRecord is PendingExecutionGroup; + export type CompletedExecutionGroup = SuccessfulExecutionGroup | FailedExecutionGroup; diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index b491ae75741..a0381c9daef 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -3052,7 +3052,28 @@ describe("ApolloClient", () => { await expect(() => client.query({ query })).rejects.toThrow( new InvariantError( - "`@defer` is not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + ) + ); + }); + + test("will error when used with `@stream` in a without specifying an incremental strategy", async () => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const query = gql` + query { + items @stream { + bar + } + } + `; + + await expect(() => client.query({ query })).rejects.toThrow( + new InvariantError( + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." ) ); }); diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index c7343506bff..82141a6b4b0 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -145,6 +145,7 @@ exports[`exports of public entry points @apollo/client/incremental 1`] = ` Array [ "Defer20220824Handler", "GraphQL17Alpha2Handler", + "GraphQL17Alpha9Handler", "NotImplementedHandler", ] `; @@ -356,14 +357,21 @@ Array [ "ObservableStream", "actAsync", "addDelayToMocks", + "asyncIterableSubject", "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", "enableFakeTimers", + "executeSchemaGraphQL17Alpha2", + "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", + "friendListSchemaGraphQL17Alpha2", + "friendListSchemaGraphQL17Alpha9", "markAsStreaming", - "mockDeferStream", + "mockDefer20220824", + "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "promiseWithResolvers", "renderAsync", "renderHookAsync", "resetApolloContext", @@ -423,6 +431,7 @@ Array [ "canonicalStringify", "checkDocument", "cloneDeep", + "combineLatestBatched", "compact", "createFragmentMap", "createFulfilledPromise", diff --git a/src/__tests__/fetchMore.ts b/src/__tests__/fetchMore.ts index 73fdf2af688..7c863397dc1 100644 --- a/src/__tests__/fetchMore.ts +++ b/src/__tests__/fetchMore.ts @@ -19,7 +19,7 @@ import { Defer20220824Handler } from "@apollo/client/incremental"; import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; import { markAsStreaming, - mockDeferStream, + mockDefer20220824, ObservableStream, setupPaginatedCase, } from "@apollo/client/testing/internal"; @@ -2478,7 +2478,7 @@ test("uses updateQuery to update the result of the query with no-cache queries", }); test("calling `fetchMore` on an ObservableQuery that hasn't finished deferring yet will not put it into completed state", async () => { - const defer = mockDeferStream(); + const defer = mockDefer20220824(); const baseLink = new MockSubscriptionLink(); const client = new ApolloClient({ diff --git a/src/__tests__/local-state/general.ts b/src/__tests__/local-state/general.ts index 69281772d3b..6771eab00c5 100644 --- a/src/__tests__/local-state/general.ts +++ b/src/__tests__/local-state/general.ts @@ -26,6 +26,13 @@ import { } from "@apollo/client/testing/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; +const WARNINGS = { + MISSING_RESOLVER: + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + NO_CACHE: + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", +}; + describe("General functionality", () => { test("should not impact normal non-@client use", async () => { const query = gql` @@ -632,7 +639,7 @@ describe("Cache manipulation", () => { }); expect(read).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledWith(null, expect.anything()); + expect(read).toHaveBeenCalledWith(undefined, expect.anything()); expect(console.warn).not.toHaveBeenCalled(); }); }); @@ -1510,3 +1517,149 @@ test("throws when executing subscriptions with client fields when local state is ) ); }); + +test.each(["cache-first", "network-only"] as const)( + "sets existing value of `@client` field to undefined when read function is present", + async (fetchPolicy) => { + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const read = jest.fn((value = "Fallback") => value); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + read, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect( + client.query({ query, fetchPolicy }) + ).resolves.toStrictEqualTyped({ + data: { + user: { __typename: "User", firstName: "Fallback", lastName: "Smith" }, + }, + }); + + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith(undefined, expect.anything()); + } +); + +test("sets existing value of `@client` field to null and warns when using no-cache with read function", async () => { + using _ = spyOnConsole("warn"); + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const read = jest.fn((value) => value ?? "Fallback"); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + read, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect( + client.query({ query, fetchPolicy: "no-cache" }) + ).resolves.toStrictEqualTyped({ + data: { + user: { __typename: "User", firstName: null, lastName: "Smith" }, + }, + }); + + expect(read).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.NO_CACHE, + "User.firstName" + ); +}); + +test("sets existing value of `@client` field to null and warns when merge function but not read function is present", async () => { + using _ = spyOnConsole("warn"); + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const merge = jest.fn(() => "Fallback"); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + merge, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect(client.query({ query })).resolves.toStrictEqualTyped({ + data: { + user: { + __typename: "User", + firstName: "Fallback", + lastName: "Smith", + }, + }, + }); + + expect(merge).toHaveBeenCalledTimes(1); + expect(merge).toHaveBeenCalledWith(undefined, null, expect.anything()); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.firstName" + ); +}); diff --git a/src/cache/core/__tests__/cache.watchFragment/types.test.ts b/src/cache/core/__tests__/cache.watchFragment/types.test.ts new file mode 100644 index 00000000000..7ee8b472a27 --- /dev/null +++ b/src/cache/core/__tests__/cache.watchFragment/types.test.ts @@ -0,0 +1,277 @@ +import { expectTypeOf } from "expect-type"; + +import type { + DataValue, + Reference, + StoreObject, + TypedDocumentNode, +} from "@apollo/client"; +import { InMemoryCache } from "@apollo/client"; +import type { ApolloCache, MissingTree } from "@apollo/client/cache"; + +describe.skip("type tests", () => { + interface Item { + __typename: "Item"; + id: number; + text: string; + } + + const cache = new InMemoryCache(); + let fragment!: TypedDocumentNode>; + + test("from: null -> null", () => { + const observable = cache.watchFragment({ fragment, from: null }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf<{ + data: null; + dataState: "complete"; + complete: true; + missing?: never; + }>(); + }); + + test("from: StoreObject -> TData", () => { + const observable = cache.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: string -> TData", () => { + const observable = cache.watchFragment({ fragment, from: "Item:1" }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Reference -> TData", () => { + const observable = cache.watchFragment({ + fragment, + from: { __ref: "Item:1" }, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: StoreObject | null -> TData | null", () => { + const observable = cache.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 } as StoreObject | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: string | null -> TData | null", () => { + const observable = cache.watchFragment({ + fragment, + from: "Item:1" as string | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Reference | null -> TData | null", () => { + const observable = cache.watchFragment({ + fragment, + from: { __ref: "Item:1" } as Reference | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Array -> Array", () => { + const observable = cache.watchFragment({ + fragment, + from: [null], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf<{ + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + }>(); + }); + + test("from: Array -> Array", () => { + const observable = cache.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:1", { __ref: "Item:1" }], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Array | null>; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Array -> Array", () => { + const observable = cache.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, null], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloCache.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Array | null>; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); +}); diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index ac80f48f6cd..d39ed6f40d0 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -1,23 +1,40 @@ import { WeakCache } from "@wry/caches"; +import { equal } from "@wry/equality"; +import { Trie } from "@wry/trie"; import type { DocumentNode, FragmentDefinitionNode, InlineFragmentNode, } from "graphql"; import { wrap } from "optimism"; -import { Observable } from "rxjs"; +import { + distinctUntilChanged, + map, + Observable, + ReplaySubject, + share, + shareReplay, + tap, + timer, +} from "rxjs"; import type { + DataValue, GetDataState, OperationVariables, TypedDocumentNode, } from "@apollo/client"; import type { FragmentType, Unmasked } from "@apollo/client/masking"; import type { Reference, StoreObject } from "@apollo/client/utilities"; -import { cacheSizes } from "@apollo/client/utilities"; +import { cacheSizes, canonicalStringify } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import type { NoInfer } from "@apollo/client/utilities/internal"; +import type { + IsAny, + NoInfer, + Prettify, +} from "@apollo/client/utilities/internal"; import { + combineLatestBatched, equalByQuery, getApolloCacheMemoryInternals, getFragmentDefinition, @@ -33,6 +50,15 @@ import type { MissingTree } from "./types/common.js"; export type Transaction = (c: ApolloCache) => void; export declare namespace ApolloCache { + /** + * Acceptable values provided to the `from` option for `watchFragment`. + */ + export type WatchFragmentFromValue = + | StoreObject + | Reference + | FragmentType> + | string + | null; /** * Watched fragment options. */ @@ -55,7 +81,9 @@ export declare namespace ApolloCache { * * @docGroup 1. Required options */ - from: StoreObject | Reference | FragmentType> | string; + from: + | ApolloCache.WatchFragmentFromValue + | Array>; /** * Any variables that the GraphQL fragment may depend on. * @@ -85,14 +113,46 @@ export declare namespace ApolloCache { * Watched fragment results. */ export type WatchFragmentResult = - | ({ - complete: true; - missing?: never; - } & GetDataState) - | ({ - complete: false; - missing: MissingTree; - } & GetDataState); + true extends IsAny ? + | ({ + complete: true; + missing?: never; + } & GetDataState) + | ({ + complete: false; + missing?: MissingTree; + } & GetDataState) + : TData extends null | null[] ? + Prettify< + { + complete: true; + missing?: never; + } & GetDataState + > + : | Prettify< + { + complete: true; + missing?: never; + } & GetDataState + > + | { + complete: false; + missing?: MissingTree; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#data:member} */ + data: TData extends Array ? + Array | null> + : DataValue.Partial; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#dataState:member} */ + dataState: "partial"; + }; + + export interface ObservableFragment + extends Observable> { + /** + * Return the current result for the fragment. + */ + getCurrentResult: () => ApolloCache.WatchFragmentResult; + } } export abstract class ApolloCache { @@ -178,6 +238,28 @@ export abstract class ApolloCache { return null; } + // Local state API + + /** + * Determines whether a `@client` field can be resolved by the cache. Used + * when `LocalState` does not have a local resolver that can resolve the + * field. + * + * @remarks Cache implementations should return `true` if a mechanism in the + * cache is expected to provide a value for the field. `LocalState` will set + * the value of the field to `undefined` in order for the cache to handle it. + * + * Cache implementations should return `false` to indicate that it cannot + * handle resolving the field (either because it doesn't have a mechanism to + * do so, or because the user hasn't provided enough information to resolve + * the field). Returning `false` will emit a warning and set the value of the + * field to `null`. + * + * A cache that doesn't implement `resolvesClientField` will be treated the + * same as returning `false`. + */ + public resolvesClientField?(typename: string, fieldName: string): boolean; + // Transactional API // The batch method is intended to replace/subsume both performTransaction @@ -287,95 +369,287 @@ export abstract class ApolloCache { }); } + private fragmentWatches = new Trie<{ + observable?: Observable & { dirty: boolean }; + }>(true); + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array>>; + } + ): ApolloCache.ObservableFragment>>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array; + } + ): ApolloCache.ObservableFragment>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array>; + } + ): ApolloCache.ObservableFragment | null>>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: null; + } + ): ApolloCache.ObservableFragment; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: NonNullable>; + } + ): ApolloCache.ObservableFragment>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions + ): ApolloCache.ObservableFragment | null>; + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ public watchFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( options: ApolloCache.WatchFragmentOptions - ): Observable>> { + ): + | ApolloCache.ObservableFragment | null> + | ApolloCache.ObservableFragment | null>> { const { fragment, fragmentName, from, optimistic = true, - ...otherOptions + variables, } = options; - const query = this.getFragmentDoc(fragment, fragmentName); - // While our TypeScript types do not allow for `undefined` as a valid - // `from`, its possible `useFragment` gives us an `undefined` since it - // calls` cache.identify` and provides that value to `from`. We are - // adding this fix here however to ensure those using plain JavaScript - // and using `cache.identify` themselves will avoid seeing the obscure - // warning. - const id = - typeof from === "undefined" || typeof from === "string" ? - from - : this.identify(from); - - if (__DEV__) { - const actualFragmentName = - fragmentName || getFragmentDefinition(fragment).name.value; - - if (!id) { - invariant.warn( - "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.", - actualFragmentName + const query = this.getFragmentDoc( + fragment, + fragmentName + ) as TypedDocumentNode; + + const fromArray = Array.isArray(from) ? from : [from]; + + const ids = fromArray.map((value) => { + // While our TypeScript types do not allow for `undefined` as a valid + // `from`, its possible `useFragment` gives us an `undefined` since it + // calls` cache.identify` and provides that value to `from`. We are + // adding this fix here however to ensure those using plain JavaScript + // and using `cache.identify` themselves will avoid seeing the obscure + // warning. + const id = + ( + typeof value === "undefined" || + typeof value === "string" || + value === null + ) ? + value + : this.identify(value); + + if (__DEV__) { + const actualFragmentName = + fragmentName || getFragmentDefinition(fragment).name.value; + + if (id === undefined) { + invariant.warn( + "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.", + actualFragmentName + ); + } + } + + return id as string | null; + }); + + let currentResult: ApolloCache.WatchFragmentResult; + function toResult( + diffs: Array | null>> + ): ApolloCache.WatchFragmentResult { + let result: ApolloCache.WatchFragmentResult; + if (Array.isArray(from)) { + result = diffs.reduce( + (result, diff, idx) => { + result.data.push(diff.result as any); + result.complete &&= diff.complete; + result.dataState = result.complete ? "complete" : "partial"; + + if (diff.missing) { + result.missing ||= {}; + (result.missing as any)[idx] = diff.missing.missing; + } + + return result; + }, + { + data: [], + dataState: "complete", + complete: true, + } as ApolloCache.WatchFragmentResult ); + } else { + const [diff] = diffs; + result = { + // Unfortunately we forgot to allow for `null` on watchFragment in 4.0 + // when `from` is a single record. As such, we need to fallback to {} + // when diff.result is null to maintain backwards compatibility. We + // should plan to change this in v5. We do howeever support `null` if + // `from` is explicitly `null`. + // + // NOTE: Using `from` with an array will maintain `null` properly + // without the need for a similar fallback since watchFragment with + // arrays is new functionality in v4. + data: from === null ? diff.result : diff.result ?? {}, + complete: diff.complete, + dataState: diff.complete ? "complete" : "partial", + } as ApolloCache.WatchFragmentResult>; + + if (diff.missing) { + result.missing = diff.missing.missing; + } } - } - const diffOptions: Cache.DiffOptions = { - ...otherOptions, - returnPartialData: true, - id, - query, - optimistic, - }; + if (!equal(currentResult, result)) { + currentResult = result; + } - let latestDiff: Cache.DiffResult | undefined; + return currentResult; + } - return new Observable((observer) => { - return this.watch({ - ...diffOptions, - immediate: true, - callback: (diff) => { - let data = diff.result; + let subscribed = false; + const observable = + ids.length === 0 ? + emptyArrayObservable + : combineLatestBatched( + ids.map((id) => this.watchSingleFragment(id, query, options)) + ).pipe( + map(toResult), + tap({ + subscribe: () => (subscribed = true), + unsubscribe: () => (subscribed = false), + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); - // TODO: Remove this once `watchFragment` supports `null` as valid - // value emitted - if (data === null) { - data = {} as any; - } + return Object.assign(observable, { + getCurrentResult: () => { + if (subscribed && currentResult) { + return currentResult as any; + } - if ( - // Always ensure we deliver the first result - latestDiff && - equalByQuery( + const diffs = ids.map( + (id): Cache.DiffResult | null> => { + if (id === null) { + return { result: null, complete: true }; + } + + return this.diff>({ + id, query, - { data: latestDiff.result }, - { data }, - options.variables - ) - ) { - return; + returnPartialData: true, + optimistic, + variables, + }); } + ); - const result = { - data, - dataState: diff.complete ? "complete" : "partial", - complete: !!diff.complete, - } as ApolloCache.WatchFragmentResult>; + return toResult(diffs); + }, + } satisfies Pick< + | ApolloCache.ObservableFragment | null> + | ApolloCache.ObservableFragment | null>>, + "getCurrentResult" + >) as any; + } - if (diff.missing) { - result.missing = diff.missing.missing; - } + /** + * Can be overridden by subclasses to delay calling the provided callback + * until after all broadcasts have been completed - e.g. in a cache scenario + * where many watchers are notified in parallel. + */ + protected onAfterBroadcast = (cb: () => void) => cb(); + private watchSingleFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + id: string | null, + fragmentQuery: TypedDocumentNode, + options: Omit< + ApolloCache.WatchFragmentOptions, + "from" | "fragment" | "fragmentName" + > + ): Observable | null>> & { dirty: boolean } { + if (id === null) { + return nullObservable; + } - latestDiff = { ...diff, result: data } as Cache.DiffResult; - observer.next(result); - }, - }); - }); + const { optimistic = true, variables } = options; + + const cacheKey = [ + fragmentQuery, + canonicalStringify({ id, optimistic, variables }), + ]; + const cacheEntry = this.fragmentWatches.lookupArray(cacheKey); + + if (!cacheEntry.observable) { + const observable: Observable> & { + dirty?: boolean; + } = new Observable>((observer) => { + const cleanup = this.watch({ + variables, + returnPartialData: true, + id, + query: fragmentQuery, + optimistic, + immediate: true, + callback: (diff) => { + observable.dirty = true; + this.onAfterBroadcast(() => { + observer.next(diff); + observable.dirty = false; + }); + }, + }); + return () => { + cleanup(); + this.fragmentWatches.removeArray(cacheKey); + }; + }).pipe( + distinctUntilChanged((previous, current) => + equalByQuery( + fragmentQuery, + { data: previous.result }, + { data: current.result }, + options.variables + ) + ), + share({ + connector: () => new ReplaySubject(1), + // debounce so a synchronous unsubscribe+resubscribe doesn't tear down the watch and create a new one + resetOnRefCountZero: () => timer(0), + }) + ); + cacheEntry.observable = Object.assign(observable, { dirty: false }); + } + + return cacheEntry.observable; } // Make sure we compute the same (===) fragment query document every @@ -547,3 +821,20 @@ export abstract class ApolloCache { if (__DEV__) { ApolloCache.prototype.getMemoryInternals = getApolloCacheMemoryInternals; } + +const nullObservable = Object.assign( + new Observable>((observer) => { + observer.next({ result: null, complete: true }); + }), + { dirty: false } +); + +const emptyArrayObservable = new Observable< + ApolloCache.WatchFragmentResult +>((observer) => { + observer.next({ + data: [], + dataState: "complete", + complete: true, + }); +}); diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 2a16c513bc5..26eedf301bf 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1042,6 +1042,49 @@ describe("Cache", () => { }); } ); + + it("does not write @stream directive as part of the cache key", () => { + const cache = new InMemoryCache(); + + cache.writeQuery({ + data: { + list: [{ __typename: "Item", id: "1", value: 1 }], + }, + query: gql` + query { + list @stream(initialCount: 1) { + id + value + } + } + `, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + list: [{ __ref: "Item:1" }], + }, + "Item:1": { __typename: "Item", id: "1", value: 1 }, + }); + + // We should be able to read the list without the `@stream` directive and + // get back results + expect( + cache.readQuery({ + query: gql` + query { + list { + id + value + } + } + `, + }) + ).toStrictEqualTyped({ + list: [{ __typename: "Item", id: "1", value: 1 }], + }); + }); }); describe("writeFragment", () => { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index f7d90e1169e..60255baf691 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -530,9 +530,23 @@ export class InMemoryCache extends ApolloCache { return this.config.fragments?.lookup(fragmentName) || null; } + public resolvesClientField(typename: string, fieldName: string): boolean { + return !!this.policies.getReadFunction(typename, fieldName); + } + protected broadcastWatches(options?: BroadcastOptions) { if (!this.txCount) { - this.watches.forEach((c) => this.maybeBroadcastWatch(c, options)); + const prevOnAfter = this.onAfterBroadcast; + const callbacks = new Set<() => void>(); + this.onAfterBroadcast = (cb: () => void) => { + callbacks.add(cb); + }; + try { + this.watches.forEach((c) => this.maybeBroadcastWatch(c, options)); + callbacks.forEach((cb) => cb()); + } finally { + this.onAfterBroadcast = prevOnAfter; + } } } diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index b44b6eb02f6..d852dc06248 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -894,8 +894,8 @@ For more information about these options, please refer to the documentation: " have an ID or a custom merge function, or " : "", typeDotName, - { ...existing }, - { ...incoming } + Array.isArray(existing) ? [...existing] : { ...existing }, + Array.isArray(incoming) ? [...incoming] : { ...incoming } ); } diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 730012610bd..545af418a06 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -132,6 +132,15 @@ export declare namespace ApolloClient { * queries. */ incrementalHandler?: Incremental.Handler; + + /** + * @experimental + * Allows passing in "experiments", experimental features that might one day + * become part of Apollo Client's core functionality. + * Keep in mind that these features might change the core of Apollo Client. + * Do not pass in experiments that are not provided by Apollo. + */ + experiments?: ApolloClient.Experiment[]; } interface DevtoolsOptions { @@ -337,7 +346,15 @@ export declare namespace ApolloClient { > = ApolloCache.WatchFragmentOptions; export type WatchFragmentResult = - ApolloCache.WatchFragmentResult; + ApolloCache.WatchFragmentResult>; + + export interface ObservableFragment + extends Observable> { + /** + * Return the current result for the fragment. + */ + getCurrentResult: () => ApolloClient.WatchFragmentResult; + } /** * Watched query options. @@ -610,6 +627,11 @@ export declare namespace ApolloClient { variables?: TVariables; } } + + export interface Experiment { + (this: ApolloClient, options: ApolloClient.Options): void; + v: 1; + } } /** @@ -708,6 +730,7 @@ export class ApolloClient { dataMasking, link, incrementalHandler = new NotImplementedHandler(), + experiments = [], } = options; this.link = link; @@ -759,6 +782,8 @@ export class ApolloClient { } if (this.devtoolsConfig.enabled) this.connectToDevTools(); + + experiments.forEach((experiment) => experiment.call(this, options)); } private connectToDevTools() { @@ -1097,42 +1122,114 @@ export class ApolloClient { * the cache to identify the fragment and optionally specify whether to react * to optimistic updates. */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array>>; + } + ): ApolloClient.ObservableFragment>; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array; + } + ): ApolloClient.ObservableFragment>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array>; + } + ): ApolloClient.ObservableFragment>; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: null; + } + ): ApolloClient.ObservableFragment; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: NonNullable>; + } + ): ApolloClient.ObservableFragment; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions + ): ApolloClient.ObservableFragment; public watchFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( options: ApolloClient.WatchFragmentOptions - ): Observable>> { + ): + | ApolloClient.ObservableFragment + | ApolloClient.ObservableFragment> { const dataMasking = this.queryManager.dataMasking; + const observable = this.cache.watchFragment({ + ...options, + fragment: this.transform(options.fragment, dataMasking), + }); - return this.cache - .watchFragment({ - ...options, - fragment: this.transform(options.fragment, dataMasking), - }) - .pipe( - map((result) => { - // The transform will remove fragment spreads from the fragment - // document when dataMasking is enabled. The `maskFragment` function - // remains to apply warnings to fragments marked as - // `@unmask(mode: "migrate")`. Since these warnings are only applied - // in dev, we can skip the masking algorithm entirely for production. - if (__DEV__) { - if (dataMasking) { - const data = this.queryManager.maskFragment({ - ...options, - data: result.data, - }); - return { ...result, data } as ApolloClient.WatchFragmentResult< - MaybeMasked - >; - } - } + const mask = ( + result: ApolloClient.WatchFragmentResult + ): ApolloClient.WatchFragmentResult => { + // The transform will remove fragment spreads from the fragment + // document when dataMasking is enabled. The `mask` function + // remains to apply warnings to fragments marked as + // `@unmask(mode: "migrate")`. Since these warnings are only applied + // in dev, we can skip the masking algorithm entirely for production. + if (__DEV__) { + if (dataMasking) { + return { + ...result, + data: this.queryManager.maskFragment({ + ...options, + data: result.data, + }), + } as ApolloClient.WatchFragmentResult>; + } + } - return result as ApolloClient.WatchFragmentResult>; - }) - ); + return result; + }; + + let currentResult: ApolloClient.WatchFragmentResult; + let stableMaskedResult: ApolloClient.WatchFragmentResult; + + return Object.assign(observable.pipe(map(mask)), { + getCurrentResult: () => { + const result = observable.getCurrentResult(); + + if (result !== currentResult) { + currentResult = result as any; + stableMaskedResult = mask(currentResult); + } + + return stableMaskedResult; + }, + }) as ApolloClient.ObservableFragment; } /** diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 37218418bf5..2a16988ac9b 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -8,13 +8,16 @@ import type { Subscribable, Subscription, } from "rxjs"; -import { BehaviorSubject, Observable, share, Subject, tap } from "rxjs"; +import { BehaviorSubject, filter, Observable, share, Subject, tap } from "rxjs"; import type { Cache, MissingFieldError } from "@apollo/client/cache"; import type { MissingTree } from "@apollo/client/cache"; import type { MaybeMasked, Unmasked } from "@apollo/client/masking"; import type { DeepPartial } from "@apollo/client/utilities"; -import { isNetworkRequestInFlight } from "@apollo/client/utilities"; +import { + isNetworkRequestInFlight, + isNetworkRequestSettled, +} from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import { compact, @@ -353,6 +356,10 @@ export class ObservableQuery< return this.subject.getValue().result.networkStatus; } + private get cache() { + return this.queryManager.cache; + } + constructor({ queryManager, options, @@ -556,7 +563,7 @@ export class ObservableQuery< * @internal */ public getCacheDiff({ optimistic = true } = {}) { - return this.queryManager.cache.diff({ + return this.cache.diff({ query: this.query, variables: this.variables, returnPartialData: true, @@ -691,7 +698,7 @@ export class ObservableQuery< } }, }; - const cancelWatch = this.queryManager.cache.watch(watch); + const cancelWatch = this.cache.watch(watch); this.unsubscribeFromCache = Object.assign( () => { @@ -806,6 +813,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, TFetchVars > ): Promise>; + public fetchMore< TFetchData = TData, TFetchVars extends OperationVariables = TVariables, @@ -864,7 +872,6 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, : combinedOptions.query; let wasUpdated = false; - const isCached = this.options.fetchPolicy !== "no-cache"; if (!isCached) { @@ -877,6 +884,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const { finalize, pushNotification } = this.pushOperation( NetworkStatus.fetchMore ); + pushNotification( { source: "newNetworkStatus", @@ -885,115 +893,190 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, }, { shouldEmit: EmitBehavior.networkStatusChange } ); - return this.queryManager - .fetchQuery(combinedOptions, NetworkStatus.fetchMore) - .then((fetchMoreResult) => { - // disable the `fetchMore` override that is currently active - // the next updates caused by this should not be `fetchMore` anymore, - // but `ready` or whatever other calculated loading state is currently - // appropriate - finalize(); - if (isCached) { - // Performing this cache update inside a cache.batch transaction ensures - // any affected cache.watch watchers are notified at most once about any - // updates. Most watchers will be using the QueryInfo class, which - // responds to notifications by calling reobserveCacheFirst to deliver - // fetchMore cache results back to this ObservableQuery. - this.queryManager.cache.batch({ - update: (cache) => { - if (updateQuery) { - cache.updateQuery( - { - query: this.query, - variables: this.variables, - returnPartialData: true, - optimistic: false, - }, - (previous) => - updateQuery(previous! as any, { - fetchMoreResult: fetchMoreResult.data as any, - variables: combinedOptions.variables as TFetchVars, - }) - ); - } else { - // If we're using a field policy instead of updateQuery, the only - // thing we need to do is write the new data to the cache using - // combinedOptions.variables (instead of this.variables, which is - // what this.updateQuery uses, because it works by abusing the - // original field value, keyed by the original variables). - cache.writeQuery({ - query: combinedOptions.query, - variables: combinedOptions.variables, - data: fetchMoreResult.data as Unmasked, - }); - } - }, - - onWatchUpdated: (watch) => { - if (watch.watcher === this) { - wasUpdated = true; - } - }, - }); - } else { - // There is a possibility `lastResult` may not be set when - // `fetchMore` is called which would cause this to crash. This should - // only happen if we haven't previously reported a result. We don't - // quite know what the right behavior should be here since this block - // of code runs after the fetch result has executed on the network. - // We plan to let it crash in the meantime. - // - // If we get bug reports due to the `data` property access on - // undefined, this should give us a real-world scenario that we can - // use to test against and determine the right behavior. If we do end - // up changing this behavior, this may require, for example, an - // adjustment to the types on `updateQuery` since that function - // expects that the first argument always contains previous result - // data, but not `undefined`. - const lastResult = this.getCurrentResult(); - const data = updateQuery!(lastResult.data as Unmasked, { - fetchMoreResult: fetchMoreResult.data as Unmasked, - variables: combinedOptions.variables as TFetchVars, - }); - // was reportResult - pushNotification({ - kind: "N", - value: { - ...lastResult, - networkStatus: NetworkStatus.ready, - // will be overwritten anyways, just here for types sake - loading: false, - data: data as any, - dataState: - lastResult.dataState === "streaming" ? "streaming" : "complete", - }, - source: "network", - }); + const { promise, operator } = getTrackingOperatorPromise( + (value: QueryNotification.Value) => { + switch (value.kind) { + case "E": { + throw value.error; + } + case "N": { + if (value.source !== "newNetworkStatus" && !value.value.loading) { + return value.value; + } + } } + } + ); - return this.maskResult(fetchMoreResult); - }) - .finally(() => { - // call `finalize` a second time in case the `.then` case above was not reached - finalize(); + const { observable } = this.queryManager.fetchObservableWithInfo( + combinedOptions, + { networkStatus: NetworkStatus.fetchMore } + ); - // In case the cache writes above did not generate a broadcast - // notification (which would have been intercepted by onWatchUpdated), - // likely because the written data were the same as what was already in - // the cache, we still want fetchMore to deliver its final loading:false - // result with the unchanged data. - if (isCached && !wasUpdated) { - pushNotification( - { + const subscription = observable + .pipe( + operator, + filter( + ( + notification + ): notification is Extract< + QueryNotification.FromNetwork, + { kind: "N" } + > => notification.kind === "N" && notification.source === "network" + ) + ) + .subscribe({ + next: (notification) => { + wasUpdated = false; + const fetchMoreResult = notification.value; + + if (isNetworkRequestSettled(notification.value.networkStatus)) { + finalize(); + } + + if (isCached) { + // Performing this cache update inside a cache.batch transaction ensures + // any affected cache.watch watchers are notified at most once about any + // updates. Most watchers will be using the QueryInfo class, which + // responds to notifications by calling reobserveCacheFirst to deliver + // fetchMore cache results back to this ObservableQuery. + this.cache.batch({ + update: (cache) => { + if (updateQuery) { + cache.updateQuery( + { + query: this.query, + variables: this.variables, + returnPartialData: true, + optimistic: false, + }, + (previous) => + updateQuery(previous! as any, { + fetchMoreResult: fetchMoreResult.data as any, + variables: combinedOptions.variables as TFetchVars, + }) + ); + } else { + // If we're using a field policy instead of updateQuery, the only + // thing we need to do is write the new data to the cache using + // combinedOptions.variables (instead of this.variables, which is + // what this.updateQuery uses, because it works by abusing the + // original field value, keyed by the original variables). + cache.writeQuery({ + query: combinedOptions.query, + variables: combinedOptions.variables, + data: fetchMoreResult.data as Unmasked, + }); + } + }, + + onWatchUpdated: (watch, diff) => { + if (watch.watcher === this) { + wasUpdated = true; + const lastResult = this.getCurrentResult(); + + // Let the cache watch from resubscribeCache handle the final + // result + if (isNetworkRequestInFlight(fetchMoreResult.networkStatus)) { + pushNotification({ + kind: "N", + source: "network", + value: { + ...lastResult, + networkStatus: + ( + fetchMoreResult.networkStatus === + NetworkStatus.error + ) ? + NetworkStatus.ready + : fetchMoreResult.networkStatus, + // will be overwritten anyways, just here for types sake + loading: false, + data: diff.result, + dataState: + fetchMoreResult.dataState === "streaming" ? + "streaming" + : "complete", + }, + }); + } + } + }, + }); + } else { + // There is a possibility `lastResult` may not be set when + // `fetchMore` is called which would cause this to crash. This should + // only happen if we haven't previously reported a result. We don't + // quite know what the right behavior should be here since this block + // of code runs after the fetch result has executed on the network. + // We plan to let it crash in the meantime. + // + // If we get bug reports due to the `data` property access on + // undefined, this should give us a real-world scenario that we can + // use to test against and determine the right behavior. If we do end + // up changing this behavior, this may require, for example, an + // adjustment to the types on `updateQuery` since that function + // expects that the first argument always contains previous result + // data, but not `undefined`. + const lastResult = this.getCurrentResult(); + const data = updateQuery!(lastResult.data as Unmasked, { + fetchMoreResult: fetchMoreResult.data as Unmasked, + variables: combinedOptions.variables as TFetchVars, + }); + + pushNotification({ kind: "N", - source: "newNetworkStatus", - value: {}, - }, - { shouldEmit: EmitBehavior.force } - ); - } + value: { + ...lastResult, + networkStatus: NetworkStatus.ready, + // will be overwritten anyways, just here for types sake + loading: false, + data: data as any, + dataState: + lastResult.dataState === "streaming" ? + "streaming" + : "complete", + }, + source: "network", + }); + } + }, }); + + return preventUnhandledRejection( + promise + .then((result) => toQueryResult(this.maskResult(result))) + .finally(() => { + subscription.unsubscribe(); + if (isCached && !wasUpdated) { + finalize(); + + const lastResult = this.getCurrentResult(); + + if (lastResult.networkStatus === NetworkStatus.streaming) { + pushNotification({ + kind: "N", + source: "network", + value: { + ...lastResult, + dataState: "complete", + networkStatus: NetworkStatus.ready, + } as any, + }); + } else { + pushNotification( + { + kind: "N", + source: "newNetworkStatus", + value: {}, + }, + { shouldEmit: EmitBehavior.force } + ); + } + } + }) + ); } // XXX the subscription variables are separate from the query variables. @@ -1131,7 +1214,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, ); if (newResult) { - queryManager.cache.writeQuery({ + this.cache.writeQuery({ query: this.options.query, data: newResult, variables: this.variables, @@ -1667,8 +1750,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, if ( dirty && - (this.options.fetchPolicy == "cache-only" || - this.options.fetchPolicy == "cache-and-network" || + (this.options.fetchPolicy === "cache-only" || + this.options.fetchPolicy === "cache-and-network" || !this.activeOperations.size) ) { const diff = this.getCacheDiff(); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 7b239329eb4..83a2bcccc39 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -331,6 +331,7 @@ export class QueryManager { optimisticResponse: isOptimistic ? optimisticResponse : void 0, }, variables, + fetchPolicy, {}, false ) @@ -748,7 +749,7 @@ export class QueryManager { ): SubscriptionObservable> { let { query, variables } = options; const { - fetchPolicy, + fetchPolicy = "cache-first", errorPolicy = "none", context = {}, extensions = {}, @@ -785,6 +786,7 @@ export class QueryManager { query, context, variables, + fetchPolicy, extensions ); @@ -864,7 +866,8 @@ export class QueryManager { private getObservableFromLink( query: DocumentNode, context: DefaultContext | undefined, - variables?: OperationVariables, + variables: OperationVariables, + fetchPolicy: WatchQueryFetchPolicy, extensions?: Record, // Prefer context.queryDeduplication if specified. deduplication: boolean = context?.queryDeduplication ?? @@ -994,6 +997,7 @@ export class QueryManager { remoteResult: result as FormattedExecutionResult, context, variables, + fetchPolicy, }) ); }) @@ -1041,7 +1045,8 @@ export class QueryManager { return this.getObservableFromLink( linkDocument, options.context, - options.variables + options.variables, + options.fetchPolicy ).observable.pipe( map((incoming) => { // Use linkDocument rather than queryInfo.document so the @@ -1602,6 +1607,7 @@ export class QueryManager { variables, onlyRunForcedResolvers: true, returnPartialData: true, + fetchPolicy, }).then( (resolved): QueryNotification.FromCache => ({ kind: "N", diff --git a/src/core/__tests__/ApolloClient/general.test.ts b/src/core/__tests__/ApolloClient/general.test.ts index eccc8b5245d..588141c8e8a 100644 --- a/src/core/__tests__/ApolloClient/general.test.ts +++ b/src/core/__tests__/ApolloClient/general.test.ts @@ -9,12 +9,10 @@ import type { ObservableQuery, TypedDocumentNode } from "@apollo/client"; import { ApolloClient, NetworkStatus } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { CombinedGraphQLErrors } from "@apollo/client/errors"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { ClientAwarenessLink } from "@apollo/client/link/client-awareness"; import { MockLink } from "@apollo/client/testing"; import { - mockDeferStream, ObservableStream, spyOnConsole, wait, @@ -7548,160 +7546,6 @@ 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 ApolloLink.RequestHandler); - const defer = mockDeferStream(); - const client = new ApolloClient({ - cache: new InMemoryCache({}), - link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), - incrementalHandler: new Defer20220824Handler(), - }); - - 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: ObservableQuery.Result = { - data: initialData, - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }; - - defer.enqueueInitialChunk({ - data: initialData, - hasNext: true, - }); - - await expect(query1).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - await expect(query2).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - - await expect(query1).toEmitTypedValue(initialResult); - await expect(query2).toEmitTypedValue(initialResult); - - const query3 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - await expect(query3).toEmitTypedValue(initialResult); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); - - const firstChunk = { - incremental: [ - { - data: { - name: "Leia", - }, - path: ["people", "friends", 0], - }, - ], - hasNext: true, - }; - const resultAfterFirstChunk = structuredClone( - initialResult - ) as ObservableQuery.Result; - resultAfterFirstChunk.data.people.friends[0].name = "Leia"; - - defer.enqueueSubsequentChunk(firstChunk); - - await expect(query1).toEmitTypedValue(resultAfterFirstChunk); - await expect(query2).toEmitTypedValue(resultAfterFirstChunk); - await expect(query3).toEmitTypedValue(resultAfterFirstChunk); - - const query4 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - await expect(query4).toEmitTypedValue(resultAfterFirstChunk); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); - - const secondChunk = { - incremental: [ - { - data: { - name: "Han Solo", - }, - path: ["people", "friends", 1], - }, - ], - hasNext: false, - }; - const resultAfterSecondChunk = { - ...structuredClone(resultAfterFirstChunk), - loading: false, - networkStatus: NetworkStatus.ready, - dataState: "complete", - partial: false, - } as ObservableQuery.Result; - resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; - - defer.enqueueSubsequentChunk(secondChunk); - - await expect(query1).toEmitTypedValue(resultAfterSecondChunk); - await expect(query2).toEmitTypedValue(resultAfterSecondChunk); - await expect(query3).toEmitTypedValue(resultAfterSecondChunk); - await expect(query4).toEmitTypedValue(resultAfterSecondChunk); - - // TODO: Re-enable once below condition can be met - /* const query5 = */ new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we - // get the loading state. This test fails with the switch to RxJS for now - // since the initial value is emitted synchronously unlike zen-observable - // where the emitted result wasn't emitted until after this assertion. - // expect(query5).not.toEmitAnything(); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); - }); }); describe("missing cache field warnings", () => { diff --git a/src/core/__tests__/ApolloClient/multiple-results.test.ts b/src/core/__tests__/ApolloClient/multiple-results.test.ts index 466e02c920e..1706bb859d7 100644 --- a/src/core/__tests__/ApolloClient/multiple-results.test.ts +++ b/src/core/__tests__/ApolloClient/multiple-results.test.ts @@ -13,7 +13,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -29,7 +29,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -82,7 +81,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -98,7 +97,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -165,7 +163,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -181,7 +179,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -241,7 +238,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -257,7 +254,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -317,7 +313,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } diff --git a/src/core/__tests__/client.watchFragment/arrays.test.ts b/src/core/__tests__/client.watchFragment/arrays.test.ts new file mode 100644 index 00000000000..dec386f1885 --- /dev/null +++ b/src/core/__tests__/client.watchFragment/arrays.test.ts @@ -0,0 +1,1030 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ObservableStream, wait } from "@apollo/client/testing/internal"; + +test("can use array for `from` to get array of items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("allows mix of array identifiers", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", { __ref: "Item:3" }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 3, text: "Item #3" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns empty array with empty from", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ fragment, from: [] }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [], + dataState: "complete", + complete: true, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("returns result as partial when cache is empty", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:1 object", + 1: "Dangling reference to missing Item:2 object", + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as complete if all `from` items are null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, null], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as complete if all `from` items are complete or null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, { __typename: "Item", id: 5, text: "Item #5" }], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as partial if some `from` items are incomplete mixed with null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("can use static arrays with useFragment with partially fulfilled items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { 2: "Dangling reference to missing Item:5 object" }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("updates items in the array with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("works with data masking", async () => { + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const parentObservable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const childObservable = client.watchFragment({ + fragment: detailsFragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const parentStream = new ObservableStream(parentObservable); + const childStream = new ObservableStream(childObservable); + + await expect(parentStream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + await expect(parentStream).not.toEmitAnything(); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1 from batch" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(parentStream).not.toEmitAnything(); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + await expect(parentStream).not.toEmitAnything(); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(parentStream).not.toEmitAnything(); + await expect(childStream).not.toEmitAnything(); +}); + +test("can subscribe to the same object multiple times", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + const stream1 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + }) + ); + // ensure we only watch the item once + expect(cache).toHaveNumWatches(1); + + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 1, text: "Item #1" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: `Item #1 updated` }, + }); + + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + const stream2 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + }) + ); + expect(cache).toHaveNumWatches(1); + + await expect(stream2).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + const stream3 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 1 }, + ], + }) + ); + expect(cache).toHaveNumWatches(2); + + await expect(stream3).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: `Item #1 updated again` }, + }); + + await expect(stream3).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream2).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + + await Promise.all([ + expect(stream1).not.toEmitAnything(), + expect(stream2).not.toEmitAnything(), + expect(stream3).not.toEmitAnything(), + ]); + + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true }, + { id: "Item:2", optimistic: true }, + ]); + + stream3.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(1); + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true }, + ]); + + stream1.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(1); + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true }, + ]); + + stream2.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(0); +}); + +test("differentiates watches between optimistic and variables", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text(casing: $casing) + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "ITEM #1" }, + variables: { casing: "UPPER" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "item #1" }, + variables: { casing: "LOWER" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "item #2" }, + variables: { casing: "LOWER" }, + }); + + const stream1 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + variables: { casing: "UPPER" }, + }) + ); + // ensure we only watch the item once + expect(cache).toHaveNumWatches(1); + + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "ITEM #1" }, + { __typename: "Item", id: 1, text: "ITEM #1" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "ITEM #1 UPDATED" }, + variables: { casing: "UPPER" }, + }); + + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "ITEM #1 UPDATED" }, + { __typename: "Item", id: 1, text: "ITEM #1 UPDATED" }, + ], + dataState: "complete", + complete: true, + }); + + const stream2 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + variables: { casing: "LOWER" }, + }) + ); + expect(cache).toHaveNumWatches(2); + + await expect(stream2).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "item #1" }, + { __typename: "Item", id: 1, text: "item #1" }, + { __typename: "Item", id: 1, text: "item #1" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "item #1 updated" }, + variables: { casing: "LOWER" }, + }); + + await expect(stream2).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "item #1 updated" }, + { __typename: "Item", id: 1, text: "item #1 updated" }, + { __typename: "Item", id: 1, text: "item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream1).not.toEmitAnything(); + + const stream3 = new ObservableStream( + client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 1 }, + ], + variables: { casing: "LOWER" }, + optimistic: false, + }) + ); + expect(cache).toHaveNumWatches(4); + + await expect(stream3).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "item #1 updated" }, + { __typename: "Item", id: 2, text: "item #2" }, + { __typename: "Item", id: 1, text: "item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "item #1 updated again" }, + variables: { casing: "LOWER" }, + }); + + await expect(stream3).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "item #1 updated again" }, + { __typename: "Item", id: 2, text: "item #2" }, + { __typename: "Item", id: 1, text: "item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream2).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "item #1 updated again" }, + { __typename: "Item", id: 1, text: "item #1 updated again" }, + { __typename: "Item", id: 1, text: "item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream1).not.toEmitAnything(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "FULL REPLACEMENT" }, + variables: { casing: "UPPER" }, + }); + + await expect(stream1).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "FULL REPLACEMENT" }, + { __typename: "Item", id: 1, text: "FULL REPLACEMENT" }, + ], + dataState: "complete", + complete: true, + }); + await expect(stream2).not.toEmitAnything(); + await expect(stream3).not.toEmitAnything(); + + expect(cache).toHaveNumWatches(4); + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true, variables: { casing: "UPPER" } }, + { id: "Item:1", optimistic: true, variables: { casing: "LOWER" } }, + { id: "Item:1", optimistic: false, variables: { casing: "LOWER" } }, + { id: "Item:2", optimistic: false, variables: { casing: "LOWER" } }, + ]); + + stream3.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(2); + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true, variables: { casing: "UPPER" } }, + { id: "Item:1", optimistic: true, variables: { casing: "LOWER" } }, + ]); + + stream1.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(1); + expect(client).toHaveFragmentWatchesOn(fragment, [ + { id: "Item:1", optimistic: true, variables: { casing: "LOWER" } }, + ]); + + stream2.unsubscribe(); + await wait(2); + + expect(cache).toHaveNumWatches(0); +}); diff --git a/src/core/__tests__/client.watchFragment/general.test.ts b/src/core/__tests__/client.watchFragment/general.test.ts new file mode 100644 index 00000000000..33c17f91398 --- /dev/null +++ b/src/core/__tests__/client.watchFragment/general.test.ts @@ -0,0 +1,190 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ObservableStream, wait } from "@apollo/client/testing/internal"; + +test("can subscribe multiple times to watchFragment", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: { __typename: "Item", id: 1 }, + }); + + using stream1 = new ObservableStream(observable); + using stream2 = new ObservableStream(observable); + + await expect(stream1).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + await expect(stream2).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream1).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + await expect(stream2).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + await expect(stream1).not.toEmitAnything(); + await expect(stream2).not.toEmitAnything(); +}); + +test("dedupes watches when subscribing multiple times", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(cache).toHaveNumWatches(0); + + const sub1 = observable.subscribe(() => {}); + const sub2 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + const sub3 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + [sub1, sub2, sub3].forEach((sub) => sub.unsubscribe()); + await wait(0); + expect(cache).toHaveNumWatches(0); + + const sub4 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + sub4.unsubscribe(); + await wait(0); + expect(cache).toHaveNumWatches(0); +}); + +test("emits null with from: null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: null, + }); + + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: null, + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("emits empty object when data is not in the cache", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: { __typename: "Item", id: 1 }, + }); + + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: {}, + dataState: "partial", + complete: false, + missing: "Dangling reference to missing Item:1 object", + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts b/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts new file mode 100644 index 00000000000..1bb96b4149f --- /dev/null +++ b/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts @@ -0,0 +1,624 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { + ObservableStream, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +interface Item { + __typename: "Item"; + id: number; + text: string; +} + +test("returns initial result before subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); +}); + +test("returns initial emitted value after subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const diffSpy = jest.spyOn(client.cache, "diff"); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + diffSpy.mockClear(); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + expect(diffSpy).not.toHaveBeenCalled(); +}); + +test("returns most recently emitted value", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("returns updated value if changed before subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("returns referentially stable value", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + const firstResult = observable.getCurrentResult(); + expect(firstResult).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toBe(firstResult); + expect(observable.getCurrentResult()).toBe(firstResult); + expect(observable.getCurrentResult()).toBe(firstResult); + + const stream = new ObservableStream(observable); + const result = await stream.takeNext(); + + // Ensure subscribing to the observable and emitting the first value doesn't + // change the identity of the object + expect(result).toBe(firstResult); + expect(observable.getCurrentResult()).toBe(firstResult); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + // ensure it changes identity when a new value is emitted + const result2 = await stream.takeNext(); + const secondResult = observable.getCurrentResult(); + + expect(secondResult).not.toBe(firstResult); + expect(secondResult).toBe(result2); + expect(observable.getCurrentResult()).toBe(secondResult); + expect(observable.getCurrentResult()).toBe(secondResult); + expect(observable.getCurrentResult()).toBe(secondResult); +}); + +test("returns partial result with no cache data", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: {}, + dataState: "partial", + complete: false, + missing: "Dangling reference to missing Item:1 object", + }); +}); + +test("is lazy computed", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + jest.spyOn(cache, "diff"); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(cache.diff).not.toHaveBeenCalled(); + observable.getCurrentResult(); + expect(cache.diff).toHaveBeenCalledTimes(1); +}); + +test("handles arrays", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("handles arrays with an active subscription", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + observable.subscribe(); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("handles arrays with null", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); +}); + +test("works with data masking", async () => { + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const parentObservable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const childObservable = client.watchFragment({ + fragment: detailsFragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(parentObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + expect(childObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(parentObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + expect(childObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("works with data masking @unmask migrate mode", async () => { + using consoleSpy = spyOnConsole("warn"); + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + text: string; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment @unmask(mode: "migrate") + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + expect(console.warn).toHaveBeenCalledTimes(3); + for (let i = 0; i < 3; i++) { + expect(console.warn).toHaveBeenNthCalledWith( + i + 1, + expect.stringContaining("Accessing unmasked field on %s at path '%s'."), + "fragment 'ItemFragment'", + `[${i}].text` + ); + } + consoleSpy.warn.mockClear(); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + expect(console.warn).toHaveBeenCalledTimes(3); + for (let i = 0; i < 3; i++) { + expect(console.warn).toHaveBeenNthCalledWith( + i + 1, + expect.stringContaining("Accessing unmasked field on %s at path '%s'."), + "fragment 'ItemFragment'", + `[${i}].text` + ); + } +}); diff --git a/src/core/__tests__/client.watchFragment/types.test.ts b/src/core/__tests__/client.watchFragment/types.test.ts new file mode 100644 index 00000000000..962563f9921 --- /dev/null +++ b/src/core/__tests__/client.watchFragment/types.test.ts @@ -0,0 +1,281 @@ +import { expectTypeOf } from "expect-type"; + +import type { + DataValue, + Reference, + StoreObject, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client"; +import type { MissingTree } from "@apollo/client/cache"; + +describe.skip("type tests", () => { + interface Item { + __typename: "Item"; + id: number; + text: string; + } + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + let fragment!: TypedDocumentNode>; + + test("from: null -> null", () => { + const observable = client.watchFragment({ fragment, from: null }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf<{ + data: null; + dataState: "complete"; + complete: true; + missing?: never; + }>(); + }); + + test("from: StoreObject -> TData", () => { + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: string -> TData", () => { + const observable = client.watchFragment({ fragment, from: "Item:1" }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Reference -> TData", () => { + const observable = client.watchFragment({ + fragment, + from: { __ref: "Item:1" }, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: StoreObject | null -> TData | null", () => { + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 } as StoreObject | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: string | null -> TData | null", () => { + const observable = client.watchFragment({ + fragment, + from: "Item:1" as string | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Reference | null -> TData | null", () => { + const observable = client.watchFragment({ + fragment, + from: { __ref: "Item:1" } as Reference | null, + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: null; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Item; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: DataValue.Partial; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Array -> Array", () => { + const observable = client.watchFragment({ + fragment, + from: [null], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf<{ + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + }>(); + }); + + test("from: Array -> Array", () => { + const observable = client.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:1", { __ref: "Item:1" }], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Array | null>; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); + + test("from: Array -> Array", () => { + const observable = client.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, null], + }); + const result = observable.getCurrentResult(); + + expectTypeOf(observable).toEqualTypeOf< + ApolloClient.ObservableFragment> + >(); + expectTypeOf(result).toEqualTypeOf< + | { + data: Array; + dataState: "complete"; + complete: true; + missing?: never; + } + | { + data: Array | null>; + dataState: "partial"; + complete: false; + missing?: MissingTree; + } + >(); + }); +}); diff --git a/src/core/__tests__/client.watchQuery/defer20220824.test.ts b/src/core/__tests__/client.watchQuery/defer20220824.test.ts new file mode 100644 index 00000000000..d8934c96ea5 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/defer20220824.test.ts @@ -0,0 +1,349 @@ +import { gql } from "graphql-tag"; + +import type { ObservableQuery } from "@apollo/client"; +import { ApolloClient, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { ApolloLink } from "@apollo/client/link"; +import { + markAsStreaming, + mockDefer20220824, + ObservableStream, +} from "@apollo/client/testing/internal"; + +test("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 ApolloLink.RequestHandler); + const defer = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), + incrementalHandler: new Defer20220824Handler(), + }); + + 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: ObservableQuery.Result = { + data: initialData, + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }; + + defer.enqueueInitialChunk({ + data: initialData, + hasNext: true, + }); + + await expect(query1).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(query2).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(query1).toEmitTypedValue(initialResult); + await expect(query2).toEmitTypedValue(initialResult); + + const query3 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query3).toEmitTypedValue(initialResult); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const firstChunk = { + incremental: [ + { + data: { + name: "Leia", + }, + path: ["people", "friends", 0], + }, + ], + hasNext: true, + }; + const resultAfterFirstChunk = structuredClone( + initialResult + ) as ObservableQuery.Result; + resultAfterFirstChunk.data.people.friends[0].name = "Leia"; + + defer.enqueueSubsequentChunk(firstChunk); + + await expect(query1).toEmitTypedValue(resultAfterFirstChunk); + await expect(query2).toEmitTypedValue(resultAfterFirstChunk); + await expect(query3).toEmitTypedValue(resultAfterFirstChunk); + + const query4 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query4).toEmitTypedValue(resultAfterFirstChunk); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const secondChunk = { + incremental: [ + { + data: { + name: "Han Solo", + }, + path: ["people", "friends", 1], + }, + ], + hasNext: false, + }; + const resultAfterSecondChunk = { + ...structuredClone(resultAfterFirstChunk), + loading: false, + networkStatus: NetworkStatus.ready, + dataState: "complete", + partial: false, + } as ObservableQuery.Result; + resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; + + defer.enqueueSubsequentChunk(secondChunk); + + await expect(query1).toEmitTypedValue(resultAfterSecondChunk); + await expect(query2).toEmitTypedValue(resultAfterSecondChunk); + await expect(query3).toEmitTypedValue(resultAfterSecondChunk); + await expect(query4).toEmitTypedValue(resultAfterSecondChunk); + + // TODO: Re-enable once below condition can be met + /* const query5 = */ new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we + // get the loading state. This test fails with the switch to RxJS for now + // since the initial value is emitted synchronously unlike zen-observable + // where the emitted result wasn't emitted until after this assertion. + // expect(query5).not.toEmitAnything(); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); +}); + +it.each([["cache-first"], ["no-cache"]] as const)( + "correctly merges deleted rows when receiving a deferred payload", + async (fetchPolicy) => { + const query = gql` + query Characters { + characters { + id + uppercase + ... @defer { + lowercase + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + const observable = client.watchQuery({ query, fetchPolicy }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [{ data: { lowercase: "a" }, path: ["characters", 0] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "b" }, path: ["characters", 1] }, + { data: { lowercase: "c" }, path: ["characters", 2] }, + ], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + void observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + // on refetch, the list is shorter + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ], + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: + // no-cache fetch policy doesn't merge with existing cache data, so + // the lowercase field is not added to each item + fetchPolicy === "no-cache" ? + [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ] + : [ + { + __typename: "Character", + id: 1, + uppercase: "A", + lowercase: "a", + }, + { + __typename: "Character", + id: 2, + uppercase: "B", + lowercase: "b", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "a" }, path: ["characters", 0] }, + { data: { lowercase: "b" }, path: ["characters", 1] }, + ], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + } +); diff --git a/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts new file mode 100644 index 00000000000..5464258a417 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts @@ -0,0 +1,371 @@ +import { gql } from "graphql-tag"; + +import type { ObservableQuery } from "@apollo/client"; +import { ApolloClient, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { ApolloLink } from "@apollo/client/link"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, +} from "@apollo/client/testing/internal"; + +test("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 ApolloLink.RequestHandler); + const defer = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + 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: ObservableQuery.Result = { + data: initialData, + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }; + + defer.enqueueInitialChunk({ + data: initialData, + pending: [ + { id: "0", path: ["people", "friends", 0] }, + { id: "1", path: ["people", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(query1).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(query2).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(query1).toEmitTypedValue(initialResult); + await expect(query2).toEmitTypedValue(initialResult); + + const query3 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query3).toEmitTypedValue(initialResult); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const firstChunk: GraphQL17Alpha9Handler.SubsequentResult< + Record + > = { + incremental: [ + { + data: { + name: "Leia", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }; + const resultAfterFirstChunk = structuredClone( + initialResult + ) as ObservableQuery.Result; + resultAfterFirstChunk.data.people.friends[0].name = "Leia"; + + defer.enqueueSubsequentChunk(firstChunk); + + await expect(query1).toEmitTypedValue(resultAfterFirstChunk); + await expect(query2).toEmitTypedValue(resultAfterFirstChunk); + await expect(query3).toEmitTypedValue(resultAfterFirstChunk); + + const query4 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query4).toEmitTypedValue(resultAfterFirstChunk); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const secondChunk: GraphQL17Alpha9Handler.SubsequentResult< + Record + > = { + incremental: [ + { + data: { + name: "Han Solo", + }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }; + const resultAfterSecondChunk = { + ...structuredClone(resultAfterFirstChunk), + loading: false, + networkStatus: NetworkStatus.ready, + dataState: "complete", + partial: false, + } as ObservableQuery.Result; + resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; + + defer.enqueueSubsequentChunk(secondChunk); + + await expect(query1).toEmitTypedValue(resultAfterSecondChunk); + await expect(query2).toEmitTypedValue(resultAfterSecondChunk); + await expect(query3).toEmitTypedValue(resultAfterSecondChunk); + await expect(query4).toEmitTypedValue(resultAfterSecondChunk); + + // TODO: Re-enable once below condition can be met + /* const query5 = */ new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we + // get the loading state. This test fails with the switch to RxJS for now + // since the initial value is emitted synchronously unlike zen-observable + // where the emitted result wasn't emitted until after this assertion. + // expect(query5).not.toEmitAnything(); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); +}); + +it.each([["cache-first"], ["no-cache"]] as const)( + "correctly merges deleted rows when receiving a deferred payload", + async (fetchPolicy) => { + const query = gql` + query Characters { + characters { + id + uppercase + ... @defer { + lowercase + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const observable = client.watchQuery({ query, fetchPolicy }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }, + pending: [ + { id: "0", path: ["characters", 0] }, + { id: "1", path: ["characters", 1] }, + { id: "2", path: ["characters", 2] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [{ data: { lowercase: "a" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "b" }, id: "1" }, + { data: { lowercase: "c" }, id: "2" }, + ], + completed: [{ id: "1" }, { id: "2" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + void observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + // on refetch, the list is shorter + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ], + }, + pending: [ + { id: "0", path: ["characters", 0] }, + { id: "1", path: ["characters", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: + // no-cache fetch policy doesn't merge with existing cache data, so + // the lowercase field is not available in the refetch + fetchPolicy === "no-cache" ? + [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ] + : [ + { + __typename: "Character", + id: 1, + uppercase: "A", + lowercase: "a", + }, + { + __typename: "Character", + id: 2, + uppercase: "B", + lowercase: "b", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "a" }, id: "0" }, + { data: { lowercase: "b" }, id: "1" }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + } +); diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts new file mode 100644 index 00000000000..8216613d93a --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -0,0 +1,989 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + mockDefer20220824, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; +import { hasDirectives } from "@apollo/client/utilities/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDefer20220824(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [{ __typename: "Friend", id: "2", name: "Han" }] as any, + path: ["friendList", 1], + }, + { + items: [{ __typename: "Friend", id: "3", name: "Leia" }] as any, + path: ["friendList", 2], + }, + ], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +// TODO: Determine how to handle this case. This emits an error for the item at +// index 1 because it is non-null, but also emits the friend at index 2 to add +// to the array. This leaves us in a bit of an impossible state as +// we can't really set nonNullFriendList[1] to `null`, otherwise we violate the +// schema. Should we stop processing results if we recieve an `items: null` from +// the server indicating an error was thrown to the nearest boundary? +it.failing( + "handles errors thrown due to null returned in non-null list items after initialCount is reached", + async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); + } +); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(null); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("can use custom merge function to combine cached and streamed lists", async () => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (existing = [], incoming, { field }) => { + if (field && hasDirectives(["stream"], field)) { + const merged: any[] = []; + + for ( + let i = 0; + i < Math.max(existing.length, incoming.length); + i++ + ) { + merged[i] = + incoming[i] === undefined ? existing[i] : incoming[i]; + } + + return merged; + } + + return incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => friends.map((friend) => Promise.resolve(friend)), + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts new file mode 100644 index 00000000000..fe33f498d34 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -0,0 +1,1007 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; +import { hasDirectives } from "@apollo/client/utilities/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + pending: [{ id: "0", path: ["friendList"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: null, + }, + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { friendList: [{ __typename: "Friend", id: "1", name: "Luke" }] }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles errors thrown due to null returned in non-null list items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("can use custom merge function to combine cached and streamed lists", async () => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (existing = [], incoming, { field }) => { + if (field && hasDirectives(["stream"], field)) { + const merged: any[] = []; + + for ( + let i = 0; + i < Math.max(existing.length, incoming.length); + i++ + ) { + merged[i] = + incoming[i] === undefined ? existing[i] : incoming[i]; + } + + return merged; + } + + return incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => friends.map((friend) => Promise.resolve(friend)), + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts similarity index 88% rename from src/incremental/handlers/__tests__/defer20220824.test.ts rename to src/incremental/handlers/__tests__/defer20220824/defer.test.ts index f5795710d6b..ca7f340fba9 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -1,13 +1,6 @@ import assert from "node:assert"; -import type { - DocumentNode, - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha2"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -15,7 +8,9 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha2"; +import { from } from "rxjs"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -23,19 +18,19 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, - mockDeferStream, + mockDefer20220824, ObservableStream, } from "@apollo/client/testing/internal"; import { hasIncrementalChunks, // eslint-disable-next-line local-rules/no-relative-imports -} from "../defer20220824.js"; +} from "../../defer20220824.js"; // This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: // https://github.com/graphql/graphql-js/blob/364cd71d1a26eb6f62661efd7fa399e91332d30d/src/execution/__tests__/defer-test.ts @@ -105,41 +100,12 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -async function* run( - document: DocumentNode -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult, - FormattedExecutionResult | void -> { - const result = await experimentalExecuteIncrementally({ - schema, - document, - rootValue: {}, - }); - if ("initialResult" in result) { - yield JSON.parse( - JSON.stringify(result.initialResult) - ) as FormattedInitialIncrementalExecutionResult; - for await (const incremental of result.subsequentResults) { - yield JSON.parse( - JSON.stringify(incremental) - ) as FormattedSubsequentIncrementalExecutionResult; - } - } else { - return result; - } +function run(query: DocumentNode) { + return executeSchemaGraphQL17Alpha2(schema, query); } const schemaLink = new ApolloLink((operation) => { - return new Observable((observer) => { - void (async () => { - for await (const chunk of run(operation.query)) { - observer.next(chunk); - } - observer.complete(); - })(); - }); + return from(run(operation.query)); }); describe("graphql-js test cases", () => { @@ -166,7 +132,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -180,7 +146,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -234,7 +200,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -244,7 +210,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -275,7 +241,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -285,7 +251,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -329,7 +295,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -344,7 +310,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -380,7 +346,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -393,7 +359,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -433,7 +399,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -443,7 +409,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -482,7 +448,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -492,7 +458,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -563,7 +529,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -573,7 +539,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -589,7 +555,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -683,7 +649,7 @@ test("Defer20220824Handler can be used with `ApolloClient`", async () => { }); test("merges cache updates that happen concurrently", async () => { - const stream = mockDeferStream(); + const stream = mockDefer20220824(); const client = new ApolloClient({ link: stream.httpLink, cache: new InMemoryCache(), @@ -979,7 +945,7 @@ test("stream that returns an error but continues to stream", async () => { }); test("handles final chunk of { hasNext: false } correctly in usage with Apollo Client", async () => { - const stream = mockDeferStream(); + const stream = mockDefer20220824(); const client = new ApolloClient({ link: stream.httpLink, cache: new InMemoryCache(), @@ -1071,3 +1037,78 @@ test("handles final chunk of { hasNext: false } correctly in usage with Apollo C }); await expect(observableStream).not.toEmitAnything(); }); + +// Servers that return a `data` property in subsequent payloads are technically +// invalid, but we still want to handle cases where the server misbehaves. +// +// See the following issue for more information: +// https://github.com/apollographql/apollo-client/issues/12976 +test("ignores `data` property added to subsequent chunks by misbehaving servers", async () => { + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + enqueueInitialChunk({ + data: { hero: { __typename: "Hero", id: "1" } }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + // @ts-expect-error simulate misbehaving server + data: null, + incremental: [{ data: { name: "Luke" }, path: ["hero"] }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts new file mode 100644 index 00000000000..3f2cd4e4fed --- /dev/null +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -0,0 +1,1977 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: +// https://github.com/graphql/graphql-js/blob/042002c3d332d36c67861f5b37d39b74d54d97d4/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function run(document: DocumentNode, rootValue: unknown = {}) { + return executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + document, + rootValue + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("Execute: stream directive", () => { + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client persective, a regular graphql query + }); + + it.skip("Can handle concurrent calls to .next() without waiting", async () => { + // from a client persective, a repeat of a previous test + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the previous test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { + scalarField: null, + }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Can @defer fields that are resolved before async iterable is complete", async () => { + // from a client perspective, a repeat of the previous test + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +test("Defer20220824Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }] }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle({ friendList: [{ id: "1", name: "Luke Cached" }] }, chunk) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts new file mode 100644 index 00000000000..198df5f19dc --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -0,0 +1,2707 @@ +import assert from "node:assert"; + +import { + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + promiseWithResolvers, + wait, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: +// https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/defer-test.ts + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, + { name: "C-3PO", id: 4 }, +]; + +const deeperObject = new GraphQLObjectType({ + fields: { + foo: { type: GraphQLString }, + bar: { type: GraphQLString }, + baz: { type: GraphQLString }, + bak: { type: GraphQLString }, + }, + name: "DeeperObject", +}); + +const nestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + name: { type: GraphQLString }, + }, + name: "NestedObject", +}); + +const anotherNestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + }, + name: "AnotherNestedObject", +}); + +const hero = { + name: "Luke", + id: 1, + friends, + nestedObject, + anotherNestedObject, +}; + +const c = new GraphQLObjectType({ + fields: { + d: { type: GraphQLString }, + nonNullErrorField: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "c", +}); + +const e = new GraphQLObjectType({ + fields: { + f: { type: GraphQLString }, + }, + name: "e", +}); + +const b = new GraphQLObjectType({ + fields: { + c: { type: c }, + e: { type: e }, + }, + name: "b", +}); + +const a = new GraphQLObjectType({ + fields: { + b: { type: b }, + someField: { type: GraphQLString }, + }, + name: "a", +}); + +const g = new GraphQLObjectType({ + fields: { + h: { type: GraphQLString }, + }, + name: "g", +}); + +const heroType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + friends: { + type: new GraphQLList(friendType), + }, + nestedObject: { type: nestedObject }, + anotherNestedObject: { type: anotherNestedObject }, + }, + name: "Hero", +}); + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + }, + a: { type: a }, + g: { type: g }, + }, + name: "Query", +}); + +const schema = new GraphQLSchema({ query }); + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +function run( + document: DocumentNode, + rootValue: unknown = { hero }, + enableEarlyExecution?: boolean +) { + return executeSchemaGraphQL17Alpha9( + schema, + document, + rootValue, + enableEarlyExecution + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("graphql-js test cases", () => { + // These test cases mirror defer tests of the `graphql-js` v17.0.0-alpha.9 release: + // https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/defer-test.ts + + it("Can defer fragments containing scalar types", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can disable defer using if argument", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(if: false) + } + } + fragment NameFragment on Hero { + name + } + `; + const handler = new GraphQL17Alpha9Handler(); + const incoming = run(query); + + const { value: chunk } = await incoming.next(); + + assert(chunk); + expect(handler.isIncrementalResult(chunk)).toBe(false); + }); + + it.skip("Does not disable defer with null if argument", async () => { + // test is not interesting from a client perspective + }); + + it.skip("Does not execute deferred fragments early when not specified", async () => { + // test is not interesting from a client perspective + }); + + it.skip("Does execute deferred fragments early when specified", async () => { + // test is not interesting from a client perspective + }); + + it("Can defer fragments on the top level Query field", async () => { + const query = gql` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can defer fragments with errors on the top level Query field", async () => { + const query = gql` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + name + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: () => { + throw new Error("bad"); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + name: null, + }, + }, + errors: [ + { + message: "bad", + path: ["hero", "name"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can defer a fragment within an already deferred fragment", async () => { + const query = gql` + query HeroNameQuery { + hero { + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + id + ...NestedFragment @defer(label: "DeferNested") + } + fragment NestedFragment on Hero { + friends { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Can defer a fragment that is also not deferred, deferred fragment is first", async () => { + // from the client perspective, a regular graphql query + }); + + it.skip("Can defer a fragment that is also not deferred, non-deferred fragment is first", async () => { + // from the client perspective, a regular graphql query + }); + + it("Can defer an inline fragment", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ... on Hero @defer(label: "InlineDeferred") { + name + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Does not emit empty defer fragments", async () => { + // from the client perspective, a regular query + }); + + it("Emits children of empty defer fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer { + ... @defer { + name + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can separately emit defer fragments with different labels with varying fields", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + ... @defer(label: "DeferName") { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Separately emits defer fragments with different labels with varying subfields", async () => { + const query = gql` + query HeroNameQuery { + ... @defer(label: "DeferID") { + hero { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Separately emits defer fragments with different labels with varying subfields that return promises", async () => { + // from the client perspective, a repeat of the last one + }); + + it("Separately emits defer fragments with varying subfields of same priorities but different level of defers", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Separately emits nested defer fragments with varying subfields of same priorities but different level of defers", async () => { + const query = gql` + query HeroNameQuery { + ... @defer(label: "DeferName") { + hero { + name + ... @defer(label: "DeferID") { + id + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Initiates deferred grouped field sets only if they have been released as pending", async () => { + const query = gql` + query { + ... @defer { + a { + ... @defer { + b { + c { + d + } + } + } + } + } + ... @defer { + a { + someField + ... @defer { + b { + e { + f + } + } + } + } + } + } + `; + + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + someField: slowFieldPromise, + b: { + c: () => { + return { d: "d" }; + }, + e: () => { + return { f: "f" }; + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("someField"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + someField: "someField", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Initiates unique deferred grouped field sets after those that are common to sibling defers", async () => { + const query = gql` + query { + ... @defer { + a { + ... @defer { + b { + c { + d + } + } + } + } + } + ... @defer { + a { + ... @defer { + b { + c { + d + } + e { + f + } + } + } + } + } + } + `; + + const { promise: cPromise, resolve: resolveC } = + promiseWithResolvers(); + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { + c: async () => { + await cPromise; + return { d: "d" }; + }, + e: () => { + return { f: "f" }; + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveC(); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can deduplicate multiple defers on the same object", async () => { + const query = gql` + query { + hero { + friends { + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + } + } + } + } + } + } + } + + fragment FriendFrag on Friend { + id + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [{}, {}, {}], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [ + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + { id: "4", name: "C-3PO" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields present in the initial payload", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + anotherNestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + bar + } + } + anotherNestedObject { + deeperObject { + foo + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + nestedObject: { deeperObject: { foo: "foo", bar: "bar" } }, + anotherNestedObject: { deeperObject: { foo: "foo" } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + }, + }, + anotherNestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + anotherNestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields present in a parent defer payload", async () => { + const query = gql` + query { + hero { + ... @defer { + nestedObject { + deeperObject { + foo + ... @defer { + foo + bar + } + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { nestedObject: { deeperObject: { foo: "foo", bar: "bar" } } }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields with deferred fragments at multiple levels", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + foo + bar + } + ... @defer { + deeperObject { + foo + bar + baz + ... @defer { + foo + bar + baz + bak + } + } + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + nestedObject: { + deeperObject: { foo: "foo", bar: "bar", baz: "baz", bak: "bak" }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + baz: "baz", + bak: "bak", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates multiple fields from deferred fragments from different branches occurring at the same level", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + ... @defer { + foo + } + } + } + ... @defer { + nestedObject { + deeperObject { + ... @defer { + foo + bar + } + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { nestedObject: { deeperObject: { foo: "foo", bar: "bar" } } }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: {}, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicate fields with deferred fragments in different branches at multiple non-overlapping levels", async () => { + const query = gql` + query { + a { + b { + c { + d + } + ... @defer { + e { + f + } + } + } + } + ... @defer { + a { + b { + e { + f + } + } + } + g { + h + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + g: { h: "h" }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + g: { + h: "h", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Correctly bundles varying subfields into incremental data records unique by defer combination, ignoring fields in a fragment masked by a parent defer", async () => { + const query = gql` + query HeroNameQuery { + ... @defer { + hero { + id + } + } + ... @defer { + hero { + name + shouldBeWithNameDespiteAdditionalDefer: name + ... @defer { + shouldBeWithNameDespiteAdditionalDefer: name + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + shouldBeWithNameDespiteAdditionalDefer: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Nulls cross defer boundaries, null first", async () => { + const query = gql` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { b: { c: { d: "d" } }, someField: "someField" }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Nulls cross defer boundaries, value first", async () => { + const query = gql` + query { + ... @defer { + a { + b { + c { + d + } + } + } + } + a { + ... @defer { + someField + b { + c { + nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { d: "d" }, nonNullErrorFIeld: null }, + someField: "someField", + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles multiple erroring deferred grouped field sets", async () => { + const query = gql` + query { + ... @defer { + a { + b { + c { + someError: nonNullErrorField + } + } + } + } + ... @defer { + a { + b { + c { + anotherError: nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { nonNullErrorField: null } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "someError"], + }, + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "anotherError"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles multiple erroring deferred grouped field sets for the same fragment", async () => { + const query = gql` + query { + ... @defer { + a { + b { + someC: c { + d: d + } + anotherC: c { + d: d + } + } + } + } + ... @defer { + a { + b { + someC: c { + someError: nonNullErrorField + } + anotherC: c { + anotherError: nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { d: "d", nonNullErrorField: null } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + someC: { d: "d" }, + anotherC: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "someC", "someError"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("filters a payload with a null that cannot be merged", async () => { + const query = gql` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + a: { + b: { + c: { + d: "d", + nonNullErrorField: async () => { + await resolveOnNextTick(); + return null; + }, + }, + }, + someField: "someField", + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Cancels deferred fields when initial result exhibits null bubbling", async () => { + // from the client perspective, a regular graphql query + }); + + it("Cancels deferred fields when deferred result exhibits null bubbling", async () => { + const query = gql` + query { + ... @defer { + hero { + nonNullName + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + hero: { + ...hero, + nonNullName: () => null, + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: null, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Deduplicates list fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates async iterable list fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates empty async iterable list fields", async () => { + // from the client perspective, a regular query + }); + + it("Does not deduplicate list fields with non-overlapping fields", async () => { + const query = gql` + query { + hero { + friends { + name + } + ... @defer { + friends { + id + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [ + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + { id: "4", name: "C-3PO" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Deduplicates list fields that return empty lists", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates null object fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates promise object fields", async () => { + // from the client perspective, a regular query + }); + + it("Handles errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: () => { + throw new Error("bad"); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: null, + }, + }, + errors: [ + { + message: "bad", + path: ["hero", "name"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles non-nullable errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles non-nullable errors thrown outside deferred fragments", async () => { + // from the client perspective, a regular query + }); + + it("Handles async non-nullable errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + nonNullName: () => Promise.resolve(null), + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: async () => { + await resolveOnNextTick(); + return "slow"; + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "slow", + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns payloads from synchronous data in correct order", async () => { + // from the client perspective, a repeat of the last one + }); + + it.skip("Filters deferred payloads when a list item returned by an async iterable is nulled", async () => { + // from the client perspective, a regular query + }); + + it.skip("original execute function throws error if anything is deferred and everything else is sync", () => { + // not relevant for the client + }); + + it.skip("original execute function resolves to error if anything is deferred and something else is async", async () => { + // not relevant for the client + }); +}); + +test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink(), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + job + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + hero: { + __typename: "Hero", + id: "1", + job: "Farmer", + }, + }, + pending: [{ id: "0", path: ["hero"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + job: "Farmer", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Hero:1", + fragment: gql` + fragment HeroJob on Hero { + job + } + `, + data: { + job: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + data: { + name: "Luke", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + job: "Jedi", // updated from cache + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("returns error on initial result", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ + hero: { + ...hero, + nonNullName: null, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + nonNullName + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: null, + }, + error: new CombinedGraphQLErrors({ + data: { + hero: null, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("stream that returns an error but continues to stream", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ + hero: { + ...hero, + nonNullName: null, + name: async () => { + await wait(100); + return "slow"; + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + nonNullName + } + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + error: new CombinedGraphQLErrors({ + data: { + hero: { + __typename: "Hero", + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "slow", + }, + }, + error: new CombinedGraphQLErrors({ + data: { + hero: { + __typename: "Hero", + id: "1", + name: "slow", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + partial: false, + }); +}); + +test("handles final chunk of { hasNext: false } correctly in usage with Apollo Client", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ProductsQuery { + allProducts { + id + nonNullErrorField + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + stream.enqueueInitialChunk({ + data: { + allProducts: [null, null, null], + }, + pending: [], + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + hasNext: true, + }); + + stream.enqueueSubsequentChunk({ + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + allProducts: [null, null, null], + }), + error: new CombinedGraphQLErrors({ + data: { + allProducts: [null, null, null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }), + }); + await expect(observableStream).not.toEmitAnything(); +}); + +// Servers that return a `data` property in subsequent payloads are technically +// invalid, but we still want to handle cases where the server misbehaves. +// +// See the following issue for more information: +// https://github.com/apollographql/apollo-client/issues/12976 +test("ignores `data` property added to subsequent chunks by misbehaving servers", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + stream.enqueueInitialChunk({ + data: { hero: { __typename: "Hero", id: "1" } }, + pending: [{ id: "0", path: ["hero"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + stream.enqueueSubsequentChunk({ + // @ts-expect-error simulate misbehaving server + data: null, + incremental: [{ data: { name: "Luke" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts new file mode 100644 index 00000000000..bded4641629 --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -0,0 +1,2882 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: +// https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +function run( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false +) { + return executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + document, + rootValue, + enableEarlyExecution + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("graphql-js test cases", () => { + // These test cases mirror stream tests of the `graphql-js` v17.0.0-alpha.9 release: + // https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Does not execute early if not specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list with nested promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f) => ({ + name: Promise.resolve(f.name), + id: Promise.resolve(f.id), + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not execute early if not specified, when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + + const slowFriend = async (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const order: Array = []; + const slowFriend = (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can handle concurrent calls to .next() without waiting", async () => { + const query = gql(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null, friends[1]], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async generator for a non-nullable list", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable does not provide a return method) ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable provides concurrent next/return methods and has a slow return ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Filters payloads that are nulled by a later synchronous error", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles overlapping deferred and non-deferred streams", async () => { + const query = gql` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [{ id: "1", name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + // this test does not exist in the original test suite but added to ensure + // deferred non-empty lists are properly merged + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream with > 0 initialCount", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved before async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1", name: "Luke Cached" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 0ed7cd97f59..1d4e3d1fbb3 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -29,27 +29,38 @@ export declare namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; export type SubsequentResult> = { - data?: TData | null | undefined; - errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; - export type Chunk> = - | InitialResult - | SubsequentResult; - - export type IncrementalDeferPayload> = { - data?: TData | null | undefined; + export type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; + + export type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + + export type IncrementalResult> = + | IncrementalDeferResult + | IncrementalStreamResult; + + export type Chunk> = + | InitialResult + | SubsequentResult; } class DeferRequest> @@ -62,12 +73,15 @@ class DeferRequest> private extensions: Record = {}; private data: any = {}; - private mergeIn( + private merge( normalized: FormattedExecutionResult, - merger: DeepMerger + arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate" ) { if (normalized.data !== undefined) { - this.data = merger.merge(this.data, normalized.data); + this.data = new DeepMerger(undefined, { arrayMerge }).merge( + this.data, + normalized.data + ); } if (normalized.errors) { this.errors.push(...normalized.errors); @@ -84,30 +98,42 @@ class DeferRequest> this.hasNext = chunk.hasNext; this.data = cacheData; - this.mergeIn(chunk, new DeepMerger()); - if (hasIncrementalChunks(chunk)) { - const merger = new DeepMerger(); for (const incremental of chunk.incremental) { - let { data, path, errors, extensions } = incremental; - if (data && path) { + const { path, errors, extensions } = incremental; + let arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate"; + let data = + // The item merged from a `@stream` chunk is always the first item in + // the `items` array + "items" in incremental ? incremental.items?.[0] + // Ensure `data: null` isn't merged for `@defer` responses by + // falling back to `undefined` + : "data" in incremental ? incremental.data ?? undefined + : undefined; + + if (data !== undefined && path) { for (let i = path.length - 1; i >= 0; --i) { const key = path[i]; const isNumericKey = !isNaN(+key); const parent: Record = isNumericKey ? [] : {}; + if (isNumericKey) { + arrayMerge = "combine"; + } parent[key] = data; data = parent as typeof data; } } - this.mergeIn( + this.merge( { errors, extensions, data: data ? (data as TData) : undefined, }, - merger + arrayMerge ); } + } else { + this.merge(chunk); } const result: FormattedExecutionResult = { data: this.data }; @@ -151,7 +177,9 @@ export class Defer20220824Handler } }; if (this.isIncrementalResult(result)) { - push(result); + if ("errors" in result) { + push(result); + } if (hasIncrementalChunks(result)) { result.incremental.forEach(push); } @@ -162,7 +190,7 @@ export class Defer20220824Handler } prepareRequest(request: ApolloLink.Request): ApolloLink.Request { - if (hasDirectives(["defer"], request.query)) { + if (hasDirectives(["defer", "stream"], request.query)) { const context = request.context ?? {}; const http = (context.http ??= {}); http.accept = [ diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts new file mode 100644 index 00000000000..fa08b8b4169 --- /dev/null +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -0,0 +1,308 @@ +import type { + DocumentNode, + FormattedExecutionResult, + GraphQLFormattedError, +} from "graphql"; + +import type { ApolloLink } from "@apollo/client/link"; +import type { DeepPartial, HKT } from "@apollo/client/utilities"; +import { DeepMerger } from "@apollo/client/utilities/internal"; +import { + hasDirectives, + isNonEmptyArray, +} from "@apollo/client/utilities/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +import type { Incremental } from "../types.js"; + +export declare namespace GraphQL17Alpha9Handler { + interface GraphQL17Alpha9Result extends HKT { + arg1: unknown; // TData + arg2: unknown; // TExtensions + return: GraphQL17Alpha9Handler.Chunk>; + } + + export interface TypeOverrides { + AdditionalApolloLinkResultTypes: GraphQL17Alpha9Result; + } + + export type InitialResult> = { + data: TData; + errors?: ReadonlyArray; + pending: ReadonlyArray; + hasNext: boolean; + extensions?: Record; + }; + + export type SubsequentResult = { + hasNext: boolean; + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + extensions?: Record; + }; + + export interface PendingResult { + id: string; + path: Incremental.Path; + label?: string; + } + + export interface CompletedResult { + id: string; + errors?: ReadonlyArray; + } + + export interface IncrementalDeferResult> { + errors?: ReadonlyArray; + data: TData; + id: string; + subPath?: Incremental.Path; + extensions?: Record; + } + + export interface IncrementalStreamResult> { + errors?: ReadonlyArray; + items: TData; + id: string; + subPath?: Incremental.Path; + extensions?: Record; + } + + export type IncrementalResult = + | IncrementalDeferResult + | IncrementalStreamResult; + + export type Chunk = InitialResult | SubsequentResult; +} + +class IncrementalRequest + implements + Incremental.IncrementalRequest, TData> +{ + hasNext = true; + + private data: any = {}; + private errors: GraphQLFormattedError[] = []; + private extensions: Record = {}; + private pending: GraphQL17Alpha9Handler.PendingResult[] = []; + // `streamPositions` maps `pending.id` to the index that should be set by the + // next `incremental` stream chunk to ensure the streamed array item is placed + // at the correct point in the data array. `this.data` contains cached + // references with the full array so we can't rely on the array length in + // `this.data` to determine where to place item. This also ensures that items + // updated by the cache between a streamed chunk aren't overwritten by merges + // of future stream items from already merged stream items. + private streamPositions: Record = {}; + + handle( + cacheData: TData | DeepPartial | null | undefined = this.data, + chunk: GraphQL17Alpha9Handler.Chunk + ): FormattedExecutionResult { + this.hasNext = chunk.hasNext; + this.data = cacheData; + + if (chunk.pending) { + this.pending.push(...chunk.pending); + + if ("data" in chunk) { + for (const pending of chunk.pending) { + const dataAtPath = pending.path.reduce( + (data, key) => (data as any)[key], + chunk.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pending.id] = dataAtPath.length; + } + } + } + } + + if (hasIncrementalChunks(chunk)) { + for (const incremental of chunk.incremental) { + const pending = this.pending.find(({ id }) => incremental.id === id); + + invariant( + pending, + "Could not find pending chunk for incremental value. Please file an issue for the Apollo Client team to investigate." + ); + + const path = pending.path.concat(incremental.subPath ?? []); + + let data: any; + let arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate"; + if ("items" in incremental) { + const items = incremental.items as any[]; + const parent: any[] = []; + + // This creates a sparse array with values set at the indices streamed + // from the server. DeepMerger uses Object.keys and will correctly + // place the values in this array in the correct place + for (let i = 0; i < items.length; i++) { + parent[i + this.streamPositions[pending.id]] = items[i]; + } + + this.streamPositions[pending.id] += items.length; + data = parent; + } else { + data = incremental.data; + + // Check if any pending streams added arrays from deferred data so + // that we can update streamPositions with the initial length of the + // array to ensure future streamed items are inserted at the right + // starting index. + for (const pendingItem of this.pending) { + if (!(pendingItem.id in this.streamPositions)) { + // Check if this incremental data contains array data for the pending path + // The pending path is absolute, but incremental data is relative to the defer + // E.g., pending.path = ["nestedObject"], pendingItem.path = ["nestedObject", "nestedFriendList"] + // incremental.data = { scalarField: "...", nestedFriendList: [...] } + // So we need the path from pending.path onwards + const relativePath = pendingItem.path.slice(pending.path.length); + const dataAtPath = relativePath.reduce( + (data, key) => (data as any)?.[key], + incremental.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pendingItem.id] = dataAtPath.length; + } + } + } + } + + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = data; + if (typeof key === "number") { + arrayMerge = "combine"; + } + data = parent; + } + + this.merge( + { + data, + extensions: incremental.extensions, + errors: incremental.errors, + }, + arrayMerge + ); + } + } else { + this.merge(chunk, "truncate"); + } + + if ("completed" in chunk && chunk.completed) { + for (const completed of chunk.completed) { + this.pending = this.pending.filter(({ id }) => id !== completed.id); + + if (completed.errors) { + this.errors.push(...completed.errors); + } + } + } + + const result: FormattedExecutionResult = { data: this.data }; + + if (isNonEmptyArray(this.errors)) { + result.errors = this.errors; + } + + if (Object.keys(this.extensions).length > 0) { + result.extensions = this.extensions; + } + + return result; + } + + private merge( + normalized: FormattedExecutionResult, + arrayMerge: DeepMerger.ArrayMergeStrategy + ) { + if (normalized.data !== undefined) { + this.data = new DeepMerger(undefined, { arrayMerge }).merge( + this.data, + normalized.data + ); + } + + if (normalized.errors) { + this.errors.push(...normalized.errors); + } + + Object.assign(this.extensions, normalized.extensions); + } +} + +/** + * Provides handling for the incremental delivery specification implemented by + * graphql.js version `17.0.0-alpha.9`. + */ +export class GraphQL17Alpha9Handler + implements Incremental.Handler> +{ + /** @internal */ + isIncrementalResult( + result: ApolloLink.Result + ): result is + | GraphQL17Alpha9Handler.InitialResult + | GraphQL17Alpha9Handler.SubsequentResult { + return "hasNext" in result; + } + + /** @internal */ + prepareRequest(request: ApolloLink.Request): ApolloLink.Request { + if (hasDirectives(["defer", "stream"], request.query)) { + const context = request.context ?? {}; + const http = (context.http ??= {}); + // https://specs.apollo.dev/incremental/v0.2/ + http.accept = [ + "multipart/mixed;incrementalSpec=v0.2", + ...(http.accept || []), + ]; + + request.context = context; + } + + return request; + } + + /** @internal */ + extractErrors(result: ApolloLink.Result) { + const acc: GraphQLFormattedError[] = []; + const push = ({ + errors, + }: { + errors?: ReadonlyArray; + }) => { + if (errors) { + acc.push(...errors); + } + }; + + if (this.isIncrementalResult(result)) { + push(new IncrementalRequest().handle(undefined, result)); + } else if ("errors" in result) { + push(result); + } + + if (acc.length) { + return acc; + } + } + + /** @internal */ + startRequest(_: { query: DocumentNode }) { + return new IncrementalRequest(); + } +} + +function hasIncrementalChunks( + result: Record +): result is Required { + return isNonEmptyArray(result.incremental); +} diff --git a/src/incremental/handlers/notImplemented.ts b/src/incremental/handlers/notImplemented.ts index f4c02545c57..ce7f287ac00 100644 --- a/src/incremental/handlers/notImplemented.ts +++ b/src/incremental/handlers/notImplemented.ts @@ -22,8 +22,8 @@ export class NotImplementedHandler implements Incremental.Handler { } prepareRequest(request: ApolloLink.Request) { invariant( - !hasDirectives(["defer"], request.query), - "`@defer` is not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + !hasDirectives(["defer", "stream"], request.query), + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." ); return request; diff --git a/src/incremental/index.ts b/src/incremental/index.ts index c340efe8574..334b0dcc826 100644 --- a/src/incremental/index.ts +++ b/src/incremental/index.ts @@ -4,3 +4,4 @@ export { Defer20220824Handler, Defer20220824Handler as GraphQL17Alpha2Handler, } from "./handlers/defer20220824.js"; +export { GraphQL17Alpha9Handler } from "./handlers/graphql17Alpha9.js"; diff --git a/src/link/context/__tests__/index.ts b/src/link/context/__tests__/index.ts index 6a5a7c8d13d..d5ff313c81b 100644 --- a/src/link/context/__tests__/index.ts +++ b/src/link/context/__tests__/index.ts @@ -1,3 +1,5 @@ +import assert from "node:assert"; + import { gql } from "graphql-tag"; import { Observable, of } from "rxjs"; @@ -268,6 +270,8 @@ test("can access the client from operation argument", async () => { const link = withContext.concat(mockLink); const stream = new ObservableStream(execute(link, { query }, { client })); - const { data } = await stream.takeNext(); - expect(data!.client).toBe(client); + const result = await stream.takeNext(); + + assert("data" in result); + expect(result.data!.client).toBe(client); }); diff --git a/src/link/error/__tests__/index.ts b/src/link/error/__tests__/index.ts index 50e814e811e..92928c77746 100644 --- a/src/link/error/__tests__/index.ts +++ b/src/link/error/__tests__/index.ts @@ -13,7 +13,7 @@ import { ApolloLink } from "@apollo/client/link"; import { ErrorLink } from "@apollo/client/link/error"; import { executeWithDefaultContext as execute, - mockDeferStream, + mockDefer20220824, mockMultipartSubscriptionStream, ObservableStream, wait, @@ -214,7 +214,7 @@ describe("error handling", () => { const errorLink = new ErrorLink(callback); const { httpLink, enqueueInitialChunk, enqueueErrorChunk } = - mockDeferStream(); + mockDefer20220824(); const link = errorLink.concat(httpLink); const stream = new ObservableStream(execute(link, { query })); diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index a60975fd827..53586aeb351 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -19,7 +19,10 @@ import { PROTOCOL_ERRORS_SYMBOL, ServerParseError, } from "@apollo/client/errors"; -import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { BaseHttpLink, HttpLink } from "@apollo/client/link/http"; import { @@ -57,6 +60,15 @@ const sampleDeferredQuery = gql` } `; +const sampleStreamedQuery = gql` + query SampleDeferredQuery { + stubs @stream { + id + name + } + } +`; + const sampleQueryCustomDirective = gql` query SampleDeferredQuery { stub { @@ -1341,6 +1353,57 @@ describe("HttpLink", () => { "-----", ].join("\r\n"); + const bodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"pending":[{"id":"0","path":["stub"]}],"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"name":"stubby---"},"id":"0","extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBody = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"id":"1","name":"stubby---"},"path":["stubs", 1],"extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"pending": [{"id":"0","path":["stubs"]}], "hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"items":[{"id":"1","name":"stubby---"}],"id":"0","extensions":{"timestamp":1633038919}}],"completed":[{"id":"0"}]}', + "-----", + ].join("\r\n"); + const finalChunkOnlyHasNextFalse = [ "--graphql", "content-type: application/json", @@ -1524,6 +1587,169 @@ describe("HttpLink", () => { ); }); + it("sets correct accept header on request with deferred query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + bodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleDeferredQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stub: { id: "0" } }, + // @ts-ignore + pending: [{ id: "0", path: ["stub"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { name: "stubby---" }, + // @ts-ignore + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query", async () => { + const stream = ReadableStream.from( + streamBody.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { id: "1", name: "stubby---" }, + path: ["stubs", 1], + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + streamBodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + // @ts-ignore + pending: [{ id: "0", path: ["stubs"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + // @ts-ignore + items: [{ id: "1", name: "stubby---" }], + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + // ensure that custom directives beginning with '@defer..' do not trigger // custom accept header for multipart responses it("sets does not set accept header on query with custom directive begging with @defer", async () => { diff --git a/src/local-state/LocalState.ts b/src/local-state/LocalState.ts index f25c7c9c873..49601ba59ec 100644 --- a/src/local-state/LocalState.ts +++ b/src/local-state/LocalState.ts @@ -22,6 +22,7 @@ import type { ErrorLike, OperationVariables, TypedDocumentNode, + WatchQueryFetchPolicy, } from "@apollo/client"; import { cacheSlot } from "@apollo/client/cache"; import { LocalStateError, toErrorLike } from "@apollo/client/errors"; @@ -63,6 +64,7 @@ interface ExecContext { exportedVariableDefs: Record; diff: Cache.DiffResult; returnPartialData: boolean; + fetchPolicy?: WatchQueryFetchPolicy; } /** @@ -336,6 +338,7 @@ export class LocalState< variables = {} as TVariables, onlyRunForcedResolvers = false, returnPartialData = false, + fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; @@ -345,6 +348,7 @@ export class LocalState< variables: TVariables | undefined; onlyRunForcedResolvers?: boolean; returnPartialData?: boolean; + fetchPolicy: WatchQueryFetchPolicy; }): Promise> { if (__DEV__) { invariant( @@ -372,12 +376,15 @@ export class LocalState< const rootValue = remoteResult ? remoteResult.data : {}; - const diff = client.cache.diff>({ - query: toQueryOperation(document), - variables, - returnPartialData: true, - optimistic: false, - }); + const diff: Cache.DiffResult> = + fetchPolicy === "no-cache" ? + { result: null, complete: false } + : client.cache.diff>({ + query: toQueryOperation(document), + variables, + returnPartialData: true, + optimistic: false, + }); const requestContext = { ...client.defaultContext, ...context }; const execContext: ExecContext = { @@ -401,6 +408,7 @@ export class LocalState< exportedVariableDefs, diff, returnPartialData, + fetchPolicy, }; const localResult = await this.resolveSelectionSet( @@ -676,9 +684,10 @@ export class LocalState< variables, operationDefinition, phase, - returnPartialData, onlyRunForcedResolvers, + fetchPolicy, } = execContext; + let { returnPartialData } = execContext; const isRootField = parentSelectionSet === operationDefinition.selectionSet; const fieldName = field.name.value; const typename = @@ -709,7 +718,25 @@ export class LocalState< return fieldFromCache; } + if (client.cache.resolvesClientField?.(typename, fieldName)) { + if (fetchPolicy === "no-cache") { + invariant.warn( + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", + resolverName + ); + return null; + } + + // assume the cache will handle returning the correct value + returnPartialData = true; + return; + } + if (!returnPartialData) { + invariant.warn( + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + resolverName + ); return null; } } diff --git a/src/local-state/__tests__/LocalState/aliases.test.ts b/src/local-state/__tests__/LocalState/aliases.test.ts index 197dcc48c78..b88b438c370 100644 --- a/src/local-state/__tests__/LocalState/aliases.test.ts +++ b/src/local-state/__tests__/LocalState/aliases.test.ts @@ -41,6 +41,7 @@ test("resolves @client fields mixed with aliased server fields", async () => { context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -81,6 +82,7 @@ test("resolves aliased @client fields", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { fie: { bar: true, __typename: "Foo" } }, @@ -137,6 +139,7 @@ test("resolves deeply nested aliased @client fields", async () => { context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -190,6 +193,7 @@ test("respects aliases for *nested fields* on the @client-tagged node", async () context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -231,6 +235,7 @@ test("does not confuse fields aliased to each other", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -269,6 +274,7 @@ test("does not confuse fields aliased to each other with boolean values", async context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/async.test.ts b/src/local-state/__tests__/LocalState/async.test.ts index 5d4ff21f0bf..a8079360b3f 100644 --- a/src/local-state/__tests__/LocalState/async.test.ts +++ b/src/local-state/__tests__/LocalState/async.test.ts @@ -33,6 +33,7 @@ test("supports async @client resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { isLoggedIn: true }, @@ -130,6 +131,7 @@ test("handles nested asynchronous @client resolvers", async () => { context: {}, variables: { id: developerId }, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -201,6 +203,7 @@ test("supports async @client resolvers mixed with remotely resolved data", async context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/base.test.ts b/src/local-state/__tests__/LocalState/base.test.ts index cc985217082..6fbbb8377a3 100644 --- a/src/local-state/__tests__/LocalState/base.test.ts +++ b/src/local-state/__tests__/LocalState/base.test.ts @@ -4,7 +4,7 @@ import { LocalState } from "@apollo/client/local-state"; import { spyOnConsole } from "@apollo/client/testing/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; -import { gql } from "./testUtils.js"; +import { gql, WARNINGS } from "./testUtils.js"; test("runs resolvers for @client queries", async () => { const document = gql` @@ -34,6 +34,7 @@ test("runs resolvers for @client queries", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -69,6 +70,7 @@ test("can add resolvers after LocalState is instantiated", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -108,6 +110,7 @@ test("handles queries with a mix of @client and server fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -165,6 +168,7 @@ test("runs resolvers for deeply nested @client fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -214,6 +218,7 @@ test("has access to query variables in @client resolvers", async () => { context: {}, variables: { id: 1 }, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -266,6 +271,7 @@ test("combines local @client resolver results with server results, for the same context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -309,6 +315,7 @@ test("handles resolvers that return booleans", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { isInCart: false }, @@ -351,6 +358,7 @@ test("does not run resolvers without @client directive", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -405,6 +413,7 @@ test("does not run resolvers without @client directive with nested field", async context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -462,6 +471,7 @@ test("allows child resolvers from a parent resolved field from a local resolver" context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -518,6 +528,7 @@ test("can use remote result to resolve @client field", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -567,13 +578,14 @@ test("throws error when query does not contain client fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError("Expected document to contain `@client` fields.") ); }); -test("does not warn when a resolver is missing for an `@client` field", async () => { +test("warns and sets value to null when a resolver is missing for an `@client` field and a read function is not defined when using InMemoryCache", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -595,13 +607,57 @@ test("does not warn when a resolver is missing for an `@client` field", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null } }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Query.foo" + ); +}); + +test("does not warn when read function is defined for a `@client` field when using InMemoryCache", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo @client + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + foo: { + read: () => "Bar", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: undefined, + fetchPolicy: "cache-first", + }) + ).resolves.toStrictEqualTyped({ data: { foo: "Bar" } }); + expect(console.warn).not.toHaveBeenCalled(); }); -test("does not warn for client child fields of a server field", async () => { +test("warns and sets value to null for client child fields of a server field with no resolver or read function", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -625,14 +681,149 @@ test("does not warn for client child fields of a server field", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null } }, }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Foo.bar" + ); +}); + +test("does not warn when a read function is defined for a child `@client` field from a server field when using InMemoryCache", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo { + bar @client + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Foo: { + fields: { + bar: { + read: () => "Baz", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + const remoteResult = { data: { foo: { __typename: "Foo" } } }; + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult, + fetchPolicy: "cache-first", + }) + ).resolves.toStrictEqualTyped({ + // The `bar` field is not so that the cache can fill in the field from the + // read function. + data: { foo: { __typename: "Foo" } }, + }); + expect(console.warn).not.toHaveBeenCalled(); }); +test("warns when using a no-cache query with a read function but no resolver function", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo @client + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + foo: { + read: () => "bar", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: undefined, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ data: { foo: null } }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(WARNINGS.NO_CACHE, "Query.foo"); +}); + +test("warns when using a no-cache query with a read function but no resolver function on child @client field", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo { + bar @client + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Foo: { + fields: { + bar: { + read: () => "baz", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: { data: { foo: { __typename: "Foo" } } }, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ + data: { foo: { __typename: "Foo", bar: null } }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(WARNINGS.NO_CACHE, "Foo.bar"); +}); + test("warns when a resolver returns undefined and sets value to null", async () => { using _ = spyOnConsole("warn"); const document = gql` @@ -661,6 +852,7 @@ test("warns when a resolver returns undefined and sets value to null", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null } }); @@ -701,6 +893,7 @@ test("warns if a parent resolver omits a field with no child resolver", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true, baz: null } }, @@ -745,6 +938,7 @@ test("warns if a parent resolver omits a field and child has @client field", asy context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true, baz: null } }, @@ -791,6 +985,7 @@ test("adds an error when the __typename cannot be resolved", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -844,6 +1039,7 @@ test("can return more data than needed in resolver which is accessible by child context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: "random" } }, @@ -882,6 +1078,7 @@ test("does not execute child resolver when parent is null", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { currentUser: null }, @@ -929,6 +1126,7 @@ test("does not execute root scalar resolver data when remote data returns null", context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -979,6 +1177,7 @@ test("does not run object resolver when remote data returns null", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -1037,6 +1236,7 @@ test("does not run root resolvers when multiple client fields are defined when r context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -1084,6 +1284,7 @@ test("does not execute resolver if client field is a child of a server field whe context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, diff --git a/src/local-state/__tests__/LocalState/cache.test.ts b/src/local-state/__tests__/LocalState/cache.test.ts index 88c9d9abe82..ad8a8d4a319 100644 --- a/src/local-state/__tests__/LocalState/cache.test.ts +++ b/src/local-state/__tests__/LocalState/cache.test.ts @@ -7,7 +7,7 @@ import { import { LocalState } from "@apollo/client/local-state"; import { spyOnConsole } from "@apollo/client/testing/internal"; -import { gql } from "./testUtils.js"; +import { gql, WARNINGS } from "./testUtils.js"; test("can write to the cache with a mutation", async () => { const query = gql` @@ -45,6 +45,7 @@ test("can write to the cache with a mutation", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -104,6 +105,7 @@ test("can write to the cache with a mutation using an ID", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -174,6 +176,7 @@ test("does not overwrite __typename when writing to the cache with an id", async context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -214,6 +217,7 @@ test("reads from the cache on a root scalar field by default if a resolver is no context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: 10 } }); }); @@ -253,6 +257,7 @@ test("reads from the cache on a root object field by default if a resolver is no context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, @@ -292,6 +297,7 @@ test("handles read functions for root scalar field from cache if resolver is not context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: 10 } }); }); @@ -332,13 +338,14 @@ test("handles read functions for root object field from cache if resolver is not context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, }); }); -test("does not warn if resolver is not defined if cache does not have value", async () => { +test("warns if resolver or read function isn't defined if cache does not have value", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -360,10 +367,15 @@ test("does not warn if resolver is not defined if cache does not have value", as context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: null } }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Query.count" + ); }); test("reads from the cache on a nested scalar field by default if a resolver is not defined", async () => { @@ -401,6 +413,7 @@ test("reads from the cache on a nested scalar field by default if a resolver is context: {}, variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, isLoggedIn: true } }, @@ -461,6 +474,7 @@ test("reads from the cache with a read function on a nested scalar field if a re remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, isLoggedIn: true } }, @@ -511,6 +525,7 @@ test("reads from the cache on a nested object field by default if a resolver is remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -580,6 +595,7 @@ test("reads from the cache with a read function on a nested object field by defa remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -647,6 +663,7 @@ test("reads from the cache on a nested client field on a non-normalized object", remoteResult: { data: { user: { __typename: "User" } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -697,6 +714,7 @@ test("does not confuse field missing resolver with root field of same name on a remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -709,7 +727,11 @@ test("does not confuse field missing resolver with root field of same name on a }, }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.count" + ); }); test("does not confuse field missing resolver with root field of same name on a non-normalized record", async () => { @@ -750,6 +772,7 @@ test("does not confuse field missing resolver with root field of same name on a remoteResult: { data: { user: { __typename: "User" } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -761,7 +784,11 @@ test("does not confuse field missing resolver with root field of same name on a }, }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.count" + ); }); test("warns on undefined value if partial data is written to the cache for an object client field", async () => { @@ -814,6 +841,7 @@ test("warns on undefined value if partial data is written to the cache for an ob remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -880,6 +908,7 @@ test("uses a written cache value from a nested client field from parent resolver context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, diff --git a/src/local-state/__tests__/LocalState/context.test.ts b/src/local-state/__tests__/LocalState/context.test.ts index 4f48d68fc26..d3d870f5c9c 100644 --- a/src/local-state/__tests__/LocalState/context.test.ts +++ b/src/local-state/__tests__/LocalState/context.test.ts @@ -34,6 +34,7 @@ test("passes client in context to resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -85,6 +86,7 @@ test("can access request context in resolvers", async () => { context: { id: 1 }, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -134,6 +136,7 @@ test("can access phase in resolver context", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: "resolve" } }, @@ -174,6 +177,7 @@ test("can use custom context function used as request context", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -220,6 +224,7 @@ test("context function can merge request context and custom context", async () = context: { isRequestBarEnabled: true }, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, diff --git a/src/local-state/__tests__/LocalState/errors.test.ts b/src/local-state/__tests__/LocalState/errors.test.ts index 5b4a19e4329..3cd699924ee 100644 --- a/src/local-state/__tests__/LocalState/errors.test.ts +++ b/src/local-state/__tests__/LocalState/errors.test.ts @@ -36,6 +36,7 @@ test("handles errors thrown in a resolver", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -88,6 +89,7 @@ test("handles errors thrown in a child resolver", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null } }, @@ -146,6 +148,7 @@ test("adds errors for each field that throws errors", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null, baz: null, qux: true } }, @@ -208,6 +211,7 @@ test("handles errors thrown in a child resolver from parent array", async () => context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -283,6 +287,7 @@ test("handles errors thrown in a child resolver for an array from a single item" context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -339,6 +344,7 @@ test("serializes a thrown GraphQLError and merges extensions", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -393,6 +399,7 @@ test("overwrites localState extension from thrown GraphQLError if provided", asy context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -452,6 +459,7 @@ test("concatenates client errors with server errors", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null, baz: { __typename: "Baz", qux: null } }, @@ -502,6 +510,7 @@ test("handles errors thrown in async resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -551,6 +560,7 @@ test("handles rejected promises returned in async resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -614,6 +624,7 @@ test("handles errors thrown for resolvers on fields inside fragments", async () context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -671,6 +682,7 @@ test("handles remote errors with no local resolver errors", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/forcedResolvers.test.ts b/src/local-state/__tests__/LocalState/forcedResolvers.test.ts index eb75a0845a2..68a4f3c61f5 100644 --- a/src/local-state/__tests__/LocalState/forcedResolvers.test.ts +++ b/src/local-state/__tests__/LocalState/forcedResolvers.test.ts @@ -40,6 +40,7 @@ test("runs resolvers marked with @client(always: true)", async () => { context: {}, variables: {}, remoteResult: { data: client.readQuery({ query: document }) }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -65,6 +66,7 @@ test("runs resolvers marked with @client(always: true)", async () => { context: {}, variables: {}, remoteResult: { data: client.readQuery({ query: document }) }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -120,6 +122,7 @@ test("only runs forced resolvers for fields marked with `@client(always: true)`, variables: {}, remoteResult: undefined, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { name: "John Smith", isLoggedIn: true }, @@ -185,6 +188,7 @@ test("runs nested forced resolvers from non-forced client descendant field", asy variables: {}, remoteResult: undefined, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -238,6 +242,7 @@ test("warns for client fields without cached data and resolvers when running for variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ // Note: name is null because we are only running forced resolvers and diff --git a/src/local-state/__tests__/LocalState/fragments.test.ts b/src/local-state/__tests__/LocalState/fragments.test.ts index 892fd371fc5..df7e31482ac 100644 --- a/src/local-state/__tests__/LocalState/fragments.test.ts +++ b/src/local-state/__tests__/LocalState/fragments.test.ts @@ -52,6 +52,7 @@ test("handles @client fields inside fragments", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -100,6 +101,7 @@ test("handles a mix of @client fields with fragments and server fields", async ( context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -154,6 +156,7 @@ it("matches fragments with fragment conditions", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -200,6 +203,7 @@ test("throws when cache does not implement fragmentMatches", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError( @@ -240,6 +244,7 @@ test("does not traverse fragment when fragment spread type condition does not ma context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo" } } }); }); @@ -277,6 +282,7 @@ test("can use a fragments on interface types defined by possibleTypes", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/partialData.test.ts b/src/local-state/__tests__/LocalState/partialData.test.ts index a6eb3bf0f29..397f4862dbb 100644 --- a/src/local-state/__tests__/LocalState/partialData.test.ts +++ b/src/local-state/__tests__/LocalState/partialData.test.ts @@ -30,6 +30,7 @@ test("omits field and does not warn if resolver not defined when returnPartialDa variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, returnPartialData: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1 } }, @@ -81,6 +82,7 @@ test("omits client fields without cached values when running forced resolvers wi remoteResult: { data: { user: { __typename: "User", id: 1 } } }, returnPartialData: true, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ // Note: name is omitted because we are only running forced resolvers and diff --git a/src/local-state/__tests__/LocalState/rootValue.test.ts b/src/local-state/__tests__/LocalState/rootValue.test.ts index ba0013dd584..fb0b2f9ebc4 100644 --- a/src/local-state/__tests__/LocalState/rootValue.test.ts +++ b/src/local-state/__tests__/LocalState/rootValue.test.ts @@ -37,6 +37,7 @@ test("passes parent value as empty object to root resolver for client-only query context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -79,6 +80,7 @@ test("passes rootValue as remote result to root resolver when server fields are context: {}, variables: {}, remoteResult: { data: { bar: { __typename: "Bar", baz: true } } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/subscriptions.test.ts b/src/local-state/__tests__/LocalState/subscriptions.test.ts index 81660e35b8b..30df1bf6b3b 100644 --- a/src/local-state/__tests__/LocalState/subscriptions.test.ts +++ b/src/local-state/__tests__/LocalState/subscriptions.test.ts @@ -26,6 +26,7 @@ test("throws when given a subscription with no client fields", async () => { context: {}, variables: {}, remoteResult: { data: { field: 1 } }, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError("Expected document to contain `@client` fields.") @@ -64,6 +65,7 @@ test("adds @client fields with subscription results", async () => { context: {}, variables: {}, remoteResult: { data: { field: 1 } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { field: 1, count: 1 }, @@ -76,6 +78,7 @@ test("adds @client fields with subscription results", async () => { context: {}, variables: {}, remoteResult: { data: { field: 2 } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { field: 2, count: 2 }, diff --git a/src/local-state/__tests__/LocalState/testUtils.ts b/src/local-state/__tests__/LocalState/testUtils.ts index 4e1c07fa21e..325bbdfcc27 100644 --- a/src/local-state/__tests__/LocalState/testUtils.ts +++ b/src/local-state/__tests__/LocalState/testUtils.ts @@ -4,3 +4,10 @@ import { addTypenameToDocument } from "@apollo/client/utilities"; export const gql = (...args: Parameters) => addTypenameToDocument(origGql(...args)); + +export const WARNINGS = { + MISSING_RESOLVER: + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + NO_CACHE: + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", +}; diff --git a/src/masking/__benches__/types.bench.ts b/src/masking/__benches__/types.bench.ts index 00ca1d98c79..2f7ec4b78f8 100644 --- a/src/masking/__benches__/types.bench.ts +++ b/src/masking/__benches__/types.bench.ts @@ -3,7 +3,11 @@ import { setup } from "@ark/attest"; import { expectTypeOf } from "expect-type"; import type { TypedDocumentNode } from "@apollo/client"; -import type { MaybeMasked, Unmasked } from "@apollo/client/masking"; +import type { + FragmentType, + MaybeMasked, + Unmasked, +} from "@apollo/client/masking"; import type { DeepPartial } from "@apollo/client/utilities"; import type { ContainsFragmentsRefs } from "../internal/types.js"; @@ -267,7 +271,7 @@ test("Unmasked handles odd types", (prefix) => { bench(prefix + "unknown instantiations", () => { attest>(); - }).types([48, "instantiations"]); + }).types([46, "instantiations"]); bench(prefix + "unknown functionality", () => { expectTypeOf>().toBeUnknown(); @@ -661,3 +665,47 @@ test("Unmasked handles branded primitive types", (prefix) => { }>(); }); }); + +test("FragmentType", () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { + NameFieldsFragment: NameFieldsFragment; + }; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + } & { " $fragmentName"?: "NameFieldsFragment" }; + + type Source = UserFieldsFragment; + + const USER_FIELDS_FRAGMENT: TypedDocumentNode = {} as any; + + bench("normal usage", () => { + expectTypeOf>().toEqualTypeOf<{ + " $fragmentRefs"?: { + UserFieldsFragment: UserFieldsFragment; + }; + }>(); + }); + + bench("passing in the type of a `TypedDocumentNode`", () => { + expectTypeOf>().toEqualTypeOf<{ + " $fragmentRefs"?: { + UserFieldsFragment: UserFieldsFragment; + }; + }>(); + }); + + bench("both usages yield the same result", () => { + expectTypeOf>().toEqualTypeOf< + FragmentType + >(); + }); +}); diff --git a/src/masking/types.ts b/src/masking/types.ts index fd8002ba43d..3075585702a 100644 --- a/src/masking/types.ts +++ b/src/masking/types.ts @@ -1,3 +1,5 @@ +import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; + import type { TypeOverrides } from "@apollo/client"; import type { ApplyHKTImplementationWithDefault } from "@apollo/client/utilities/internal"; @@ -6,12 +8,17 @@ import type { PreserveTypes } from "./PreserveTypes.js"; /** * Type used with [fragments](https://apollographql.com/docs/react/data/fragments#using-with-fragments) to ensure parent objects contain the fragment spread from the type. */ -export type FragmentType = ApplyHKTImplementationWithDefault< - TypeOverrides, - "FragmentType", - PreserveTypes.TypeOverrides, - TFragmentData ->; +export type FragmentType = + ApplyHKTImplementationWithDefault< + TypeOverrides, + "FragmentType", + PreserveTypes.TypeOverrides, + TFragmentDataOrTypedDocumentNode extends ( + DocumentTypeDecoration + ) ? + TFragmentData + : TFragmentDataOrTypedDocumentNode + >; /** Unwraps `TData` into its unmasked type. */ export type Unmasked = ApplyHKTImplementationWithDefault< diff --git a/src/react/hooks/__tests__/bundle.test.tsx b/src/react/hooks/__tests__/bundle.test.tsx index 36cce74d820..8005560214e 100644 --- a/src/react/hooks/__tests__/bundle.test.tsx +++ b/src/react/hooks/__tests__/bundle.test.tsx @@ -11,7 +11,7 @@ test("test is running against source or ESM (in CI) build", () => { test("test is running against uncompiled or compiled (in CI)", () => { if (isCI) { - expect(reactCompilerVersion).toMatchInlineSnapshot(`"19.1.0-rc.2"`); + expect(reactCompilerVersion).toMatchInlineSnapshot(`"1.0.0"`); } else { expect(reactCompilerVersion).toBe("uncompiled"); } diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index d0bce5acec0..6e4c07f6e7f 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -30,7 +30,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -1391,151 +1390,6 @@ it("works with startTransition to change variables", async () => { } }); -it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - interface Data { - greeting: { - __typename: string; - message: string; - recipient: { name: string; __typename: string }; - }; - } - - const query: TypedDocumentNode = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - const renderStream = createDefaultProfiler(); - - const { SuspenseFallback, ReadQueryHook } = - createDefaultTrackedComponents(renderStream); - - function App() { - useTrackRenders(); - const [queryRef] = useBackgroundQuery(query, { - fetchPolicy: "cache-and-network", - }); - - return ( - }> - - - ); - } - - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { wrapper: createClientWrapper(client) }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender({ timeout: 50 }); -}); - it("reacts to cache updates", async () => { const { query, mocks } = setupSimpleCase(); @@ -3816,159 +3670,6 @@ it('suspends and does not use partial data when changing variables and using a " await expect(renderStream).not.toRerender({ timeout: 50 }); }); -it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - interface QueryData { - greeting: { - __typename: string; - message?: string; - recipient?: { - __typename: string; - name: string; - }; - }; - } - - const query: TypedDocumentNode = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const renderStream = createDefaultProfiler>(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultTrackedComponents(renderStream); - - function App() { - useTrackRenders(); - const [queryRef] = useBackgroundQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }); - - return ( - }> - - - ); - } - - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { wrapper: createClientWrapper(client) }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender({ timeout: 50 }); -}); - it.each([ "cache-first", "network-only", diff --git a/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..9ce0c721105 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx @@ -0,0 +1,357 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..1efc081c759 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,361 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..bff598f1047 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx @@ -0,0 +1,419 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..e5010152249 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,419 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index e1ba367c8fc..bb8d312f900 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -1439,7 +1439,7 @@ describe("useFragment", () => { }); }); - it("returns correct data when options change", async () => { + it("returns correct data when from changes", async () => { const client = new ApolloClient({ cache: new InMemoryCache(), link: ApolloLink.empty(), @@ -1498,6 +1498,72 @@ describe("useFragment", () => { await expect(takeSnapshot).not.toRerender(); }); + it("returns correct data when options change", async () => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + type User = { __typename: "User"; id: number; name: string }; + const fragment: TypedDocumentNode = gql` + fragment UserFragment on User { + id + name(casing: $casing) + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "User", id: 1, name: "ALICE" }, + variables: { casing: "upper" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "User", id: 1, name: "alice" }, + variables: { casing: "lower" }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ casing }) => + useFragment({ + fragment, + from: { __typename: "User", id: 1 }, + variables: { casing }, + }), + { + initialProps: { casing: "upper" }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toStrictEqualTyped({ + complete: true, + data: { __typename: "User", id: 1, name: "ALICE" }, + dataState: "complete", + }); + } + + await rerender({ casing: "lower" }); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toStrictEqualTyped({ + complete: true, + data: { __typename: "User", id: 1, name: "alice" }, + dataState: "complete", + }); + } + + await expect(takeSnapshot).not.toRerender(); + }); + it("does not rerender when fields with @nonreactive change", async () => { type Post = { __typename: "Post"; @@ -1770,21 +1836,19 @@ describe("useFragment", () => { } ); - { - const { data, complete } = await takeSnapshot(); - - expect(data).toStrictEqualTyped({}); - expect(complete).toBe(false); - } + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: {}, + dataState: "partial", + complete: false, + }); await rerender({ from: { __typename: "User", id: "1" } }); - { - const { data, complete } = await takeSnapshot(); - - expect(data).toStrictEqualTyped({ __typename: "User", id: "1", age: 30 }); - expect(complete).toBe(true); - } + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { __typename: "User", id: "1", age: 30 }, + dataState: "complete", + complete: true, + }); }); describe("tests with incomplete data", () => { @@ -2371,20 +2435,6 @@ describe("has the same timing as `useQuery`", () => { id: cache.identify(item2), }); - { - // unintended extra render - const { withinDOM } = await renderStream.takeRender(); - const parent = withinDOM().getByTestId("parent"); - const children = withinDOM().getByTestId("children"); - - expect(within(parent).queryAllByText(/Item #1/).length).toBe(1); - expect(within(children).queryAllByText(/Item #1/).length).toBe(1); - - // problem: useFragment renders before useQuery catches up - expect(within(parent).queryAllByText(/Item #2/).length).toBe(1); - expect(within(children).queryAllByText(/Item #2/).length).toBe(0); - } - { const { withinDOM } = await renderStream.takeRender(); const parent = withinDOM().getByTestId("parent"); @@ -2570,7 +2620,13 @@ describe.skip("Type Tests", () => { expectTypeOf< useFragment.Options >().branded.toEqualTypeOf<{ - from: string | StoreObject | Reference | FragmentType | null; + from: + | string + | StoreObject + | Reference + | FragmentType + | null + | Array | null>; fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; optimistic?: boolean; diff --git a/src/react/hooks/__tests__/useFragment/arrays.test.tsx b/src/react/hooks/__tests__/useFragment/arrays.test.tsx new file mode 100644 index 00000000000..ae6c6cd5805 --- /dev/null +++ b/src/react/hooks/__tests__/useFragment/arrays.test.tsx @@ -0,0 +1,585 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { useFragment } from "@apollo/client/react"; +import { createClientWrapper } from "@apollo/client/testing/internal"; + +test("can use array for `from` to get array of items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns result as complete for null array item `from` value", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [null, null, null], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns as partial if some `from` items are incomplete mixed with null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("allows mix of array identifiers", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", null], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns empty array with empty from", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useFragment({ fragment, from: [] }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [], + dataState: "complete", + complete: true, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns incomplete results when cache is empty", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:1 object", + 1: "Dangling reference to missing Item:2 object", + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("can use static arrays with useFragment with partially fulfilled items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("handles changing array size", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ from }) => useFragment({ fragment, from }), + { + initialProps: { + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }, + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [{ __typename: "Item", id: 6 }], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:6 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("updates items in the array with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index 53818cc4eda..bcd4be56365 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -32,7 +32,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -1531,164 +1530,6 @@ it("works with startTransition to change variables", async () => { }); }); -it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - interface Data { - greeting: { - __typename: string; - message: string; - recipient: { name: string; __typename: string }; - }; - } - - const query: TypedDocumentNode> = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const renderStream = createDefaultProfiler(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultProfiledComponents(renderStream); - - function App() { - useTrackRenders(); - const [loadQuery, queryRef] = useLoadableQuery(query, { - fetchPolicy: "cache-and-network", - }); - return ( -
- - }> - {queryRef && } - -
- ); - } - - const { user } = await renderWithClient( - , - { - client, - }, - renderStream - ); - - // initial render - await renderStream.takeRender(); - - await user.click(screen.getByText("Load todo")); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender(); -}); - it("reacts to cache updates", async () => { const { query, mocks } = useSimpleQueryCase(); const client = new ApolloClient({ @@ -4553,174 +4394,6 @@ it('suspends and does not use partial data when changing variables and using a " } }); -it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - interface QueryData { - greeting: { - __typename: string; - message?: string; - recipient?: { - __typename: string; - name: string; - }; - }; - } - - const query: TypedDocumentNode> = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - { - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - using _consoleSpy = spyOnConsole("error"); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const renderStream = createDefaultProfiler>(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultProfiledComponents(renderStream); - - function App() { - useTrackRenders(); - const [loadTodo, queryRef] = useLoadableQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }); - - return ( -
- - }> - {queryRef && } - -
- ); - } - - const { user } = await renderWithClient( - , - { - client, - }, - renderStream - ); - - // initial render - await renderStream.takeRender(); - - await user.click(screen.getByText("Load todo")); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender(); -}); - it("throws when calling loadQuery on first render", async () => { // We don't provide this functionality with React 19 anymore since it requires internals access if (IS_REACT_19) return; diff --git a/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..26a07ede75c --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx @@ -0,0 +1,402 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderHook< + TData, + TVariables extends OperationVariables, + TStates extends DataState["dataState"] = DataState["dataState"], + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useLoadableQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ + queryRef, + }: { + queryRef: QueryRef; + }) { + useTrackRenders({ name: "useReadQuery" }); + mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = renderHook(props as any); + + mergeSnapshot({ loadQuery }); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { + render, + getCurrentRender, + takeRender, + mergeSnapshot, + replaceSnapshot, + } = createRenderStream< + | { + loadQuery: useLoadableQuery.LoadQueryFunction; + result?: useReadQuery.Result; + } + | { error: ErrorLike } + >({ initialSnapshot: { loadQuery: null as any } }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + invariant( + "loadQuery" in snapshot, + "Expected rendered hook instead of error boundary" + ); + + return snapshot; + } + + return { takeRender, rerender, getCurrentSnapshot }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => useLoadableQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + + { + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + using _consoleSpy = spyOnConsole("error"); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => + useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..c4fee82fef3 --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,406 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderHook< + TData, + TVariables extends OperationVariables, + TStates extends DataState["dataState"] = DataState["dataState"], + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useLoadableQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ + queryRef, + }: { + queryRef: QueryRef; + }) { + useTrackRenders({ name: "useReadQuery" }); + mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = renderHook(props as any); + + mergeSnapshot({ loadQuery }); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { + render, + getCurrentRender, + takeRender, + mergeSnapshot, + replaceSnapshot, + } = createRenderStream< + | { + loadQuery: useLoadableQuery.LoadQueryFunction; + result?: useReadQuery.Result; + } + | { error: ErrorLike } + >({ initialSnapshot: { loadQuery: null as any } }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + invariant( + "loadQuery" in snapshot, + "Expected rendered hook instead of error boundary" + ); + + return snapshot; + } + + return { takeRender, rerender, getCurrentSnapshot }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => useLoadableQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + + { + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + using _consoleSpy = spyOnConsole("error"); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => + useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index f4e01ba122a..3f925c5c510 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -26,10 +26,9 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { BatchHttpLink } from "@apollo/client/link/batch-http"; import { ApolloProvider, useMutation, useQuery } from "@apollo/client/react"; -import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; +import { MockLink } from "@apollo/client/testing"; import { spyOnConsole, wait } from "@apollo/client/testing/internal"; import { MockedProvider } from "@apollo/client/testing/react"; import type { DeepPartial } from "@apollo/client/utilities"; @@ -3922,381 +3921,6 @@ describe("useMutation Hook", () => { await waitFor(() => screen.findByText("item 3")); }); }); - describe("defer", () => { - const CREATE_TODO_MUTATION_DEFER = gql` - mutation createTodo($description: String!, $priority: String) { - createTodo(description: $description, priority: $priority) { - id - ... @defer { - description - priority - } - } - } - `; - const variables = { - description: "Get milk!", - }; - it("resolves a deferred mutation with the full result", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [mutate] = getCurrentSnapshot(); - - const promise = mutate({ variables }); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot).not.toRerender(); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - error: undefined, - loading: false, - called: true, - }); - } - - await expect(promise).resolves.toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }); - - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - - it("resolves with resulting errors and calls onError callback", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - const onError = jest.fn(); - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER, { onError }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [createTodo] = getCurrentSnapshot(); - - const promise = createTodo({ variables }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot).not.toRerender(); - - link.simulateResult( - { - result: { - incremental: [ - { - data: null, - errors: [{ message: CREATE_TODO_ERROR }], - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(promise).rejects.toThrow( - new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: new CombinedGraphQLErrors({ - data: { createTodo: { __typename: "Todo", id: 1 } }, - errors: [{ message: CREATE_TODO_ERROR }], - }), - loading: false, - called: true, - }); - } - - await expect(takeSnapshot).not.toRerender(); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenLastCalledWith( - new CombinedGraphQLErrors({ - data: { createTodo: { __typename: "Todo", id: 1 } }, - errors: [{ message: CREATE_TODO_ERROR }], - }), - expect.anything() - ); - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - - it("calls the update function with the final merged result data", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - const update = jest.fn(); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER, { update }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [createTodo] = getCurrentSnapshot(); - - const promiseReturnedByMutate = createTodo({ variables }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot).not.toRerender(); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - error: undefined, - loading: false, - called: true, - }); - } - - await expect(takeSnapshot).not.toRerender(); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith( - // the first item is the cache, which we don't need to make any - // assertions against in this test - expect.anything(), - // second argument is the result - expect.objectContaining({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }), - // third argument is an object containing context and variables - // but we only care about variables here - expect.objectContaining({ variables }) - ); - - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - }); }); describe("data masking", () => { diff --git a/src/react/hooks/__tests__/useMutation/context.test.tsx b/src/react/hooks/__tests__/useMutation/context.test.tsx new file mode 100644 index 00000000000..aae00dd8693 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/context.test.tsx @@ -0,0 +1,434 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import React from "react"; +import { delay, of } from "rxjs"; + +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ApolloProvider, useMutation } from "@apollo/client/react"; + +const echoContextLink = new ApolloLink((operation) => { + // filter out internal client set context values + const { queryDeduplication, optimisticResponse, ...context } = + operation.getContext(); + return of({ + data: { echo: { context } }, + }).pipe(delay(20)); +}); + +test("context is provided from hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute(); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { echo: { context: { foo: true } } }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("context provided to execute function overrides hook context", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute({ context: { bar: true } }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { echo: { context: { bar: true } } }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("allows context as callback called with context from hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn((ctx) => ({ ...ctx, bar: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith({ foo: true }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { foo: true, bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("provides undefined to context callback if context is not provided to hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn((ctx) => ({ ...ctx, bar: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith(undefined); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("does not merge returned context from context callback with hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn(() => ({ baz: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith({ foo: true }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { baz: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("provides full context returned from callback to update function", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const update = jest.fn(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true }, update }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute({ context: (ctx) => ({ ...ctx, bar: true }) }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { foo: true, bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + client.cache, + { + data: { + echo: { context: { foo: true, bar: true } }, + }, + }, + { context: { foo: true, bar: true }, variables: {} } + ); +}); diff --git a/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx b/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx new file mode 100644 index 00000000000..5319ccdc587 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx @@ -0,0 +1,389 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { gql } from "graphql-tag"; + +import { ApolloClient, CombinedGraphQLErrors } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useMutation } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const CREATE_TODO_ERROR = "Failed to create item"; + +test("resolves a deferred mutation with the full result", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate({ variables }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + expect(console.error).not.toHaveBeenCalled(); +}); + +test("resolves with resulting errors and calls onError callback", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const onError = jest.fn(); + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { onError }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promise = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: null, + errors: [{ message: CREATE_TODO_ERROR }], + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + await expect(promise).rejects.toThrow( + new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenLastCalledWith( + new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + expect.anything() + ); + expect(console.error).not.toHaveBeenCalled(); +}); + +test("calls the update function with the final merged result data", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const update = jest.fn(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { update }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promiseReturnedByMutate = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + // the first item is the cache, which we don't need to make any + // assertions against in this test + expect.anything(), + // second argument is the result + expect.objectContaining({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }), + // third argument is an object containing context and variables + // but we only care about variables here + expect.objectContaining({ variables }) + ); + + expect(console.error).not.toHaveBeenCalled(); +}); diff --git a/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..6cf690be0ef --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,388 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { gql } from "graphql-tag"; + +import { ApolloClient, CombinedGraphQLErrors } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useMutation } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const CREATE_TODO_ERROR = "Failed to create item"; + +test("resolves a deferred mutation with the full result", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate({ variables }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + expect(console.error).not.toHaveBeenCalled(); +}); + +test("resolves with resulting errors and calls onError callback", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const onError = jest.fn(); + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { onError }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promise = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + completed: [{ id: "0", errors: [{ message: CREATE_TODO_ERROR }] }], + hasNext: false, + }); + + await expect(promise).rejects.toThrow( + new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenLastCalledWith( + new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + expect.anything() + ); + expect(console.error).not.toHaveBeenCalled(); +}); + +test("calls the update function with the final merged result data", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const update = jest.fn(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { update }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promiseReturnedByMutate = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + // the first item is the cache, which we don't need to make any + // assertions against in this test + expect.anything(), + // second argument is the result + expect.objectContaining({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }), + // third argument is an object containing context and variables + // but we only care about variables here + expect.objectContaining({ variables }) + ); + + expect(console.error).not.toHaveBeenCalled(); +}); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 2d544bb11ac..5845bac6001 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -34,7 +34,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { LocalState } from "@apollo/client/local-state"; import type { Unmasked } from "@apollo/client/masking"; @@ -53,7 +52,6 @@ import type { } from "@apollo/client/testing/internal"; import { enableFakeTimers, - markAsStreaming, setupPaginatedCase, setupSimpleCase, setupVariablesCase, @@ -10191,1226 +10189,6 @@ describe("useQuery Hook", () => { }); }); - describe("defer", () => { - it("should handle deferred queries", async () => { - const query = gql` - { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { - name: "Alice", - __typename: "Person", - }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries in lists", async () => { - const query = gql` - { - greetings { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greetings", 0], - }, - ], - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { message: "Hello again", __typename: "Greeting" }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Bob", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greetings", 1], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { - message: "Hello again", - __typename: "Greeting", - recipient: { name: "Bob", __typename: "Person" }, - }, - ], - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries in lists, merging arrays", async () => { - const query = gql` - query DeferVariation { - allProducts { - delivery { - ...MyFragment @defer - } - sku - id - } - } - fragment MyFragment on DeliveryEstimates { - estimatedDelivery - fastestDelivery - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - hasNext: true, - incremental: [ - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 0, "delivery"], - }, - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 1, "delivery"], - }, - ], - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - variables: {}, - }); - }); - - it("should handle deferred queries with fetch policy no-cache", async () => { - const query = gql` - { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { fetchPolicy: "no-cache" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { - name: "Alice", - __typename: "Person", - }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries with errors returned on the incremental batched result", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - loading: false, - networkStatus: NetworkStatus.error, - previousData: undefined, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { errorPolicy: "all" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - extensions: { - thing1: "foo", - thing2: "bar", - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - extensions: { - thing1: "foo", - thing2: "bar", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - // the only difference with the previous test - // is that homeWorld is populated since errorPolicy: all - // populates both partial data and error.graphQLErrors - homeWorld: null, - id: "1000", - name: "Luke Skywalker", - }, - { - // homeWorld is populated due to errorPolicy: all - homeWorld: "Alderaan", - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { homeWorld: null, id: "1000", name: "Luke Skywalker" }, - { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - extensions: { - thing1: "foo", - thing2: "bar", - }, - }), - loading: false, - networkStatus: NetworkStatus.error, - previousData: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { fetchPolicy: "cache-and-network" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - // We know we are writing partial data to the cache so suppress the console - // warning. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => - useQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - }); - describe("interaction with `prioritizeCacheValues`", () => { const cacheData = { something: "foo" }; const emptyData = undefined; diff --git a/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..d15c2e78200 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx @@ -0,0 +1,1148 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { + ApolloClient, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +test("should handle deferred queries", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists", async () => { + const query = gql` + { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greetings", 0], + }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Bob", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greetings", 1], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { + message: "Hello again", + __typename: "Greeting", + recipient: { name: "Bob", __typename: "Person" }, + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists, merging arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + hasNext: true, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + variables: {}, + }); +}); + +test("should handle deferred queries with fetch policy no-cache", async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries with errors returned on the incremental batched result", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + // the only difference with the previous test + // is that homeWorld is populated since errorPolicy: all + // populates both partial data and error.graphQLErrors + homeWorld: null, + id: "1000", + name: "Luke Skywalker", + }, + { + // homeWorld is populated due to errorPolicy: all + homeWorld: "Alderaan", + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { homeWorld: null, id: "1000", name: "Luke Skywalker" }, + { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + extensions: { + thing1: "foo", + thing2: "bar", + }, + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..d70c095de26 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,1143 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { + ApolloClient, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +test("should handle deferred queries", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists", async () => { + const query = gql` + { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + pending: [ + { id: "0", path: ["greetings", 0] }, + { id: "1", path: ["greetings", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + { + data: { + recipient: { + name: "Bob", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { + message: "Hello again", + __typename: "Greeting", + recipient: { name: "Bob", __typename: "Person" }, + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists, merging arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + pending: [ + { id: "0", path: ["allProducts", 0, "delivery"] }, + { id: "1", path: ["allProducts", 1, "delivery"] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "0", + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + variables: {}, + }); +}); + +test("should handle deferred queries with fetch policy no-cache", async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries with errors returned on the incremental batched result", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + id: "0", + }, + { + data: { + homeWorld: "Alderaan", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + id: "0", + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + { + data: { + homeWorld: "Alderaan", + }, + id: "1", + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + // the only difference with the previous test + // is that homeWorld is populated since errorPolicy: all + // populates both partial data and error.graphQLErrors + homeWorld: null, + id: "1000", + name: "Luke Skywalker", + }, + { + // homeWorld is populated due to errorPolicy: all + homeWorld: "Alderaan", + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { homeWorld: null, id: "1000", name: "Luke Skywalker" }, + { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + extensions: { + thing1: "foo", + thing2: "bar", + }, + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..aad6b5a7618 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx @@ -0,0 +1,775 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..28a65e677f0 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,775 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx index f910da745a4..b8f0710c1d6 100644 --- a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx @@ -24,11 +24,7 @@ import { } from "@apollo/client"; import { ApolloProvider, useSuspenseFragment } from "@apollo/client/react"; import { MockSubscriptionLink } from "@apollo/client/testing"; -import { - renderAsync, - spyOnConsole, - wait, -} from "@apollo/client/testing/internal"; +import { renderAsync, spyOnConsole } from "@apollo/client/testing/internal"; import { MockedProvider } from "@apollo/client/testing/react"; import { removeDirectivesFromDocument } from "@apollo/client/utilities/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; @@ -1553,14 +1549,13 @@ test("tears down the subscription on unmount", async () => { expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); } - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(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); + // Cleanup happens async so we just need to ensure it happens sometime after + // mount + await waitFor(() => expect(cache).toHaveNumWatches(0)); }); test("tears down all watches when rendering multiple records", async () => { @@ -1617,11 +1612,10 @@ test("tears down all watches when rendering multiple records", async () => { } 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); + // Cleanup happens async so we just need to ensure it happens sometime after + // mount + await waitFor(() => expect(cache).toHaveNumWatches(0)); }); test("tears down watches after default autoDisposeTimeoutMs if component never renders again after suspending", async () => { @@ -1686,11 +1680,13 @@ test("tears down watches after default autoDisposeTimeoutMs if component never r // clear the microtask queue await act(() => Promise.resolve()); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.advanceTimersByTime(30_000); + // Run unsubscribe timeouts from cache watches + jest.runOnlyPendingTimers(); - expect(cache["watches"].size).toBe(0); + expect(cache).toHaveNumWatches(0); jest.useRealTimers(); }); @@ -1767,11 +1763,13 @@ test("tears down watches after configured autoDisposeTimeoutMs if component neve // clear the microtask queue await act(() => Promise.resolve()); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.advanceTimersByTime(5000); + // Run unsubscribe timeouts from cache watches + jest.runOnlyPendingTimers(); - expect(cache["watches"].size).toBe(0); + expect(cache).toHaveNumWatches(0); jest.useRealTimers(); }); @@ -1833,7 +1831,7 @@ test("cancels autoDisposeTimeoutMs if the component renders before timer finishe jest.advanceTimersByTime(30_000); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.useRealTimers(); }); @@ -1992,6 +1990,75 @@ describe.skip("type tests", () => { } }); + 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).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [null], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); + + test("returns Array when `from` includes null with non-null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: [null, { __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [null, { __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); + + test("returns TData[] when `from` includes array of non-null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [{ __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + }); + test("variables are optional and can be anything with an untyped DocumentNode", () => { const fragment = gql``; diff --git a/src/react/hooks/__tests__/useSuspenseFragment/arrays.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment/arrays.test.tsx new file mode 100644 index 00000000000..ae694d610a3 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseFragment/arrays.test.tsx @@ -0,0 +1,1081 @@ +import type { RenderOptions } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import { userEvent } from "@testing-library/user-event"; +import React, { Suspense } from "react"; + +import type { StoreObject, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { useSuspenseFragment } from "@apollo/client/react"; +import { createClientWrapper } from "@apollo/client/testing/internal"; + +async function renderUseSuspenseFragment( + renderHook: (props: Props) => useSuspenseFragment.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseFragment({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseFragment" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useSuspenseFragment.Result + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test("renders array and does not suspend array for `from` array when written to cache", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("updates items in the array with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + }); + } + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns null array for null `from` array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => useSuspenseFragment({ fragment, from: [null, null, null] }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [null, null, null], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("handles mixed array of identifiers in `from`", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", null], + }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns empty array for empty `from` array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => useSuspenseFragment({ fragment, from: [] }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("suspends until all items are complete", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + await expect(takeRender).not.toRerender({ timeout: 20 }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + await expect(takeRender).not.toRerender({ timeout: 20 }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends until all items are complete with partially complete results on initial render", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends when an item changes from complete to partial", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 is back" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 is back" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("handles changing array size", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseFragment( + ({ from }) => useSuspenseFragment({ fragment, from }), + { + initialProps: { + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }, + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + } + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 5 }, + ], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await rerender({ + from: [], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [], + }); + } + + await rerender({ + from: [{ __typename: "Item", id: 6 }], + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 6, text: "Item #6" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [{ __typename: "Item", id: 6, text: "Item #6" }], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("rendering same items in multiple useSuspenseFragment hooks allows for rerendering a different array in the other", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + function UseSuspenseFragment({ + id, + items, + }: { + id: number; + items: StoreObject[]; + }) { + useTrackRenders({ name: `useSuspenseFragment ${id}` }); + mergeSnapshot({ + [`items${id}`]: useSuspenseFragment({ fragment, from: items }), + }); + + return null; + } + + function SuspenseFallback({ id }: { id: number }) { + // Reset snapshot so it doesn't seem like the useSuspenseFragment hook + // rendered + mergeSnapshot({ [`items${id}`]: undefined }); + useTrackRenders({ name: `SuspenseFallback ${id}` }); + + return null; + } + + function App({ + items1, + items2, + }: { + items1: StoreObject[]; + items2: StoreObject[]; + }) { + return ( + <> + }> + + + }> + + + + ); + } + + using _disabledAct = disableActEnvironment(); + const { render, takeRender, mergeSnapshot } = createRenderStream<{ + items1: useSuspenseFragment.Result | undefined; + items2: useSuspenseFragment.Result | undefined; + }>({ + skipNonTrackingRenders: true, + initialSnapshot: { items1: undefined, items2: undefined }, + }); + + const initialItems = [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ]; + + const { rerender } = await render( + , + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "SuspenseFallback 2", + "SuspenseFallback 1", + ]); + expect(snapshot).toStrictEqualTyped({ + items1: undefined, + items2: undefined, + }); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + }); + } + await waitFor(() => expect(cache).toHaveNumWatches(2)); + + await rerender( + + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "SuspenseFallback 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: undefined, + }); + } + await waitFor(() => expect(cache).toHaveNumWatches(3)); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment 2"]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }, + }); + } + + await rerender( + + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [{ __typename: "Item", id: 2, text: "Item #2" }], + }, + }); + } + await waitFor(() => expect(cache).toHaveNumWatches(3)); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + ], + }, + items2: { + data: [{ __typename: "Item", id: 2, text: "Item #2 updated" }], + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("works with transitions", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const user = userEvent.setup(); + + function UseSuspenseFragment({ items }: { items: StoreObject[] }) { + useTrackRenders({ name: "useSuspenseFragment" }); + replaceSnapshot(useSuspenseFragment({ fragment, from: items })); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function App() { + const [items, setItems] = React.useState([ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ]); + const [isPending, startTransition] = React.useTransition(); + + return ( + <> + + }> + + + + ); + } + + using _disabledAct = disableActEnvironment(); + const { render, takeRender, replaceSnapshot } = createRenderStream< + useSuspenseFragment.Result + >({ skipNonTrackingRenders: true }); + + await render(, { wrapper: createClientWrapper(client) }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqualTyped(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqualTyped(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + } + + const button = screen.getByText("Change items"); + await user.click(button); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqual({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + expect(button).toBeDisabled(); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqual({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + expect(button).not.toBeDisabled(); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5ba0dce3d1b..31ea6583555 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -32,10 +32,7 @@ import { NetworkStatus, } from "@apollo/client"; import type { Incremental } from "@apollo/client/incremental"; -import { - Defer20220824Handler, - NotImplementedHandler, -} from "@apollo/client/incremental"; +import { NotImplementedHandler } from "@apollo/client/incremental"; import type { Unmasked } from "@apollo/client/masking"; import { ApolloProvider, @@ -50,7 +47,6 @@ import type { import { actAsync, createClientWrapper, - markAsStreaming, renderAsync, renderHookAsync, setupPaginatedCase, @@ -7137,2863 +7133,6 @@ describe("useSuspenseQuery", () => { expect(client.getObservableQueries().size).toBe(1); }); - it("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it.each([ - "cache-first", - "network-only", - "no-cache", - "cache-and-network", - ])( - 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', - async (fetchPolicy) => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - } - ); - - it('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), - { cache, incrementalHandler: new Defer20220824Handler() } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const { result, renders } = await renderSuspenseHook( - () => - useSuspenseQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }), - { cache, link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }); - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), - { client } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - message: "Hello cached", - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.loading, - error: undefined, - }); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.loading, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("suspends deferred queries with lists and properly patches results", async () => { - const query = gql` - query { - greetings { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult({ - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Alice" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Bob" }, - }, - path: ["greetings", 1], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - recipient: { __typename: "Person", name: "Bob" }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - recipient: { __typename: "Person", name: "Bob" }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("suspends queries with deferred fragments in lists and properly merges arrays", async () => { - const query = gql` - query DeferVariation { - allProducts { - delivery { - ...MyFragment @defer - } - sku - id - } - } - - fragment MyFragment on DeliveryEstimates { - estimatedDelivery - fastestDelivery - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult({ - result: { - hasNext: true, - incremental: [ - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 0, "delivery"], - }, - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 1, "delivery"], - }, - ], - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - }); - - it("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { - const query = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { client } - ); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let refetchPromise: Promise>; - await actAsync(async () => { - refetchPromise = result.current.refetch(); - }); - - link.simulateResult({ - result: { - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(refetchPromise!).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - }); - - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("incrementally renders data returned after skipping a deferred query", async () => { - const query = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, rerenderAsync, renders } = await renderSuspenseHook( - ({ skip }) => useSuspenseQuery(query, { skip }), - { client, initialProps: { skip: true } } - ); - - expect(result.current).toStrictEqualTyped({ - data: undefined, - dataState: "empty", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - await rerenderAsync({ skip: false }); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: undefined, - dataState: "empty", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - // TODO: This test is a bit of a lie. `fetchMore` should incrementally - // rerender when using `@defer` but there is currently a bug in the core - // implementation that prevents updates until the final result is returned. - // This test reflects the behavior as it exists today, but will need - // to be updated once the core bug is fixed. - // - // NOTE: A duplicate it.failng test has been added right below this one with - // the expected behavior added in (i.e. the commented code in this test). Once - // the core bug is fixed, this test can be removed in favor of the other test. - // - // https://github.com/apollographql/apollo-client/issues/11034 - it("rerenders data returned by `fetchMore` for a deferred query", async () => { - const query = gql` - query ($offset: Int) { - greetings(offset: $offset) { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - greetings: offsetLimitPagination(), - }, - }, - }, - }); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { variables: { offset: 0 } }), - { client } - ); - - link.simulateResult({ - result: { - data: { - greetings: [{ __typename: "Greeting", message: "Hello world" }], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let fetchMorePromise: Promise>; - await actAsync(() => { - fetchMorePromise = result.current.fetchMore({ variables: { offset: 1 } }); - }); - - link.simulateResult({ - result: { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }, - hasNext: true, - }, - }); - - // TODO: Re-enable once the core bug is fixed - // await waitFor(() => { - // expect(result.current).toStrictEqualTyped({ - // data: { - // greetings: [ - // { - // __typename: 'Greeting', - // message: 'Hello world', - // recipient: { - // __typename: 'Person', - // name: 'Alice', - // }, - // }, - // { - // __typename: 'Greeting', - // message: 'Goodbye', - // }, - // ], - // }, - // dataState: "streaming", - // networkStatus: NetworkStatus.streaming, - // error: undefined, - // }); - // }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(fetchMorePromise!).resolves.toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - }); - - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - // TODO: Re-enable when the core `fetchMore` bug is fixed - // { - // data: { - // greetings: [ - // { - // __typename: 'Greeting', - // message: 'Hello world', - // recipient: { - // __typename: 'Person', - // name: 'Alice', - // }, - // }, - // { - // __typename: 'Greeting', - // message: 'Goodbye', - // }, - // ], - // }, - // dataState: "streaming", - // networkStatus: NetworkStatus.streaming, - // error: undefined, - // }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - // TODO: This is a duplicate of the test above, but with the expected behavior - // added (hence the `it.failing`). Remove the previous test once issue #11034 - // is fixed. - // - // https://github.com/apollographql/apollo-client/issues/11034 - it.failing( - "incrementally rerenders data returned by a `fetchMore` for a deferred query", - async () => { - const query = gql` - query ($offset: Int) { - greetings(offset: $offset) { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - greetings: offsetLimitPagination(), - }, - }, - }, - }); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { variables: { offset: 0 } }), - { client } - ); - - link.simulateResult({ - result: { - data: { - greetings: [{ __typename: "Greeting", message: "Hello world" }], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let fetchMorePromise: Promise>; - await actAsync(() => { - fetchMorePromise = result.current.fetchMore({ - variables: { offset: 1 }, - }); - }); - - link.simulateResult({ - result: { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(fetchMorePromise!).resolves.toEqual({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - loading: false, - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - } - ); - - it("throws network errors returned by deferred queries", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - error: new Error("Could not fetch"), - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(Error); - expect(error).toEqual(new Error("Could not fetch")); - }); - - it("throws graphql errors returned by deferred queries", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - result: { - errors: [new GraphQLError("Could not fetch greeting")], - }, - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - errors: [{ message: "Could not fetch greeting" }], - }) - ); - }); - - it("throws errors returned by deferred queries that include partial data", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - result: { - data: { greeting: null }, - errors: [new GraphQLError("Could not fetch greeting")], - }, - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - data: { greeting: null }, - errors: [{ message: "Could not fetch greeting" }], - }) - ); - }); - - it("discards partial data and throws errors returned in incremental chunks", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - // This chunk is ignored since errorPolicy `none` throws away partial - // data - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(renders.errorCount).toBe(1); - }); - - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - ]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }) - ); - }); - - it("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - // Unlike the default (errorPolicy = `none`), this data will be - // added to the final result - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }, - ]); - }); - - it("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "ignore" }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { client } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }); - }); - - let refetchPromise: Promise>; - await actAsync(async () => { - refetchPromise = result.current.refetch(); - }); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - data: { - homeWorld: "Alderaan", - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(refetchPromise!).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - }); - - cache.updateQuery({ query }, (data) => ({ - hero: { - ...data.hero, - name: "C3PO", - }, - })); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "C3PO", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(7 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }, - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "C3PO", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - it("can subscribe to subscriptions and react to cache updates via `subscribeToMore`", async () => { interface SubscriptionData { greetingUpdated: string; diff --git a/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..4f5aac41f24 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx @@ -0,0 +1,2261 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { delay, of, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +test("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello cached", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends deferred queries with lists and properly patches results", async () => { + const query = gql` + query { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Bob" }, + }, + path: ["greetings", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + recipient: { __typename: "Person", name: "Bob" }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends queries with deferred fragments in lists and properly merges arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); +}); + +test("incrementally renders data returned after skipping a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a deferred query", async () => { + const query = gql` + query ($offset: Int) { + greetings(offset: $offset) { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 1 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk } = mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + errors: [{ message: "Could not fetch greeting" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws errors returned by deferred queries that include partial data", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // This chunk is ignored since errorPolicy `none` throws away partial + // data + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // Unlike the default (errorPolicy = `none`), this data will be + // added to the final result + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + data: { + homeWorld: "Alderaan", + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + hero: { + ...data.hero, + name: "C3PO", + }, + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "C3PO", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..f928f171c9f --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,2311 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { delay, of, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +test("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello cached", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends deferred queries with lists and properly patches results", async () => { + const query = gql` + query { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }, + pending: [ + { id: "0", path: ["greetings", 0] }, + { id: "1", path: ["greetings", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Bob" }, + }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + recipient: { __typename: "Person", name: "Bob" }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends queries with deferred fragments in lists and properly merges arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + pending: [ + { id: "0", path: ["allProducts", 0, "delivery"] }, + { id: "1", path: ["allProducts", 1, "delivery"] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "0", + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); +}); + +test("incrementally renders data returned after skipping a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a deferred query", async () => { + const query = gql` + query ($offset: Int) { + greetings(offset: $offset) { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }, + pending: [{ id: "0", path: ["greetings", 0] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 1 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }, + pending: [{ id: "0", path: ["greetings", 0] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws errors returned by deferred queries that include partial data", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // This chunk is ignored since errorPolicy `none` throws away partial + // data + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // Unlike the default (errorPolicy = `none`), this data will be + // added to the final result + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + data: { + homeWorld: "Alderaan", + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + hero: { + ...data.hero, + name: "C3PO", + }, + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "C3PO", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..a14e4145350 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -0,0 +1,1533 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke (refetch)" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..7a638011913 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,1561 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke (refetch)" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [{ message: "Could not get friend", path: ["friendList"] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject>(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject | Friend>; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + const iterable = asyncIterableSubject | Friend>(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/internal/useDeepMemo.ts b/src/react/hooks/internal/useDeepMemo.ts index 57e1ab96631..8459aed99a0 100644 --- a/src/react/hooks/internal/useDeepMemo.ts +++ b/src/react/hooks/internal/useDeepMemo.ts @@ -7,8 +7,10 @@ export function useDeepMemo( deps: DependencyList ) { const ref = React.useRef<{ deps: DependencyList; value: TValue }>(void 0); + // eslint-disable-next-line react-hooks/refs if (!ref.current || !equal(ref.current.deps, deps)) { ref.current = { value: memoFn(), deps }; } + // eslint-disable-next-line react-hooks/refs return ref.current.value; } diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index 6634b99adc4..66d3eff2140 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -427,7 +427,6 @@ export function useBackgroundQuery< "use no memo"; return wrapHook( "useBackgroundQuery", - // eslint-disable-next-line react-compiler/react-compiler useBackgroundQuery_, useApolloClient(typeof options === "object" ? options.client : undefined) )(query, options ?? ({} as any)); diff --git a/src/react/hooks/useFragment.ts b/src/react/hooks/useFragment.ts index 94daa7c2799..b5b35b1159c 100644 --- a/src/react/hooks/useFragment.ts +++ b/src/react/hooks/useFragment.ts @@ -1,4 +1,3 @@ -import { equal } from "@wry/equality"; import * as React from "react"; import type { @@ -9,13 +8,8 @@ import type { OperationVariables, TypedDocumentNode, } from "@apollo/client"; -import type { - Cache, - MissingTree, - Reference, - StoreObject, -} from "@apollo/client/cache"; -import type { FragmentType, MaybeMasked } from "@apollo/client/masking"; +import type { ApolloCache, MissingTree } from "@apollo/client/cache"; +import type { MaybeMasked } from "@apollo/client/masking"; import type { NoInfer } from "@apollo/client/utilities/internal"; import { useDeepMemo, wrapHook } from "./internal/index.js"; @@ -24,6 +18,7 @@ import { useSyncExternalStore } from "./useSyncExternalStore.js"; export declare namespace useFragment { import _self = useFragment; + export interface Options { /** * A GraphQL document created using the `gql` template string tag from @@ -46,14 +41,11 @@ export declare namespace useFragment { variables?: NoInfer; /** - * An object containing a `__typename` and primary key fields (such as `id`) identifying the entity object from which the fragment will be retrieved, or a `{ __ref: "..." }` reference, or a `string` ID (uncommon). + * An object or array containing a `__typename` and primary key fields + * (such as `id`) identifying the entity object from which the fragment will + * be retrieved, or a `{ __ref: "..." }` reference, or a `string` ID (uncommon). */ - from: - | StoreObject - | Reference - | FragmentType> - | string - | null; + from: useFragment.FromValue | Array>; /** * Whether to read from optimistic or non-optimistic cache data. If @@ -84,6 +76,11 @@ export declare namespace useFragment { } } + /** + * Acceptable values provided to the `from` option. + */ + export type FromValue = ApolloCache.WatchFragmentFromValue; + // TODO: Update this to return `null` when there is no data returned from the // fragment. export type Result = @@ -93,12 +90,18 @@ export declare namespace useFragment { /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#missing:member} */ missing?: never; } & GetDataState, "complete">) - | ({ + | { /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#complete:member} */ complete: false; /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#missing:member} */ missing?: MissingTree; - } & GetDataState, "partial">); + /** {@inheritDoc @apollo/client!QueryResultDocumentation#data:member} */ + data: TData extends Array ? + Array | null> + : DataValue.Partial; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#dataState:member} */ + dataState: "partial"; + }; export namespace DocumentationTypes { namespace useFragment { @@ -138,11 +141,47 @@ export declare namespace useFragment { export function useFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, ->(options: useFragment.Options): useFragment.Result { +>( + options: useFragment.Options & { + from: Array>>; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options & { + from: Array; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options & { + from: Array>; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(options: useFragment.Options): useFragment.Result; + +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options +): useFragment.Result | useFragment.Result> { "use no memo"; return wrapHook( "useFragment", - // eslint-disable-next-line react-compiler/react-compiler useFragment_, useApolloClient(options.client) )(options); @@ -150,115 +189,75 @@ export function useFragment< function useFragment_( options: useFragment.Options -): useFragment.Result { +): useFragment.Result | useFragment.Result> { const client = useApolloClient(options.client); - const { cache } = client; const { from, ...rest } = options; + const { cache } = client; - // We calculate the cache id seperately from `stableOptions` because we don't - // want changes to non key fields in the `from` property to affect - // `stableOptions` and retrigger our subscription. If the cache identifier - // stays the same between renders, we want to reuse the existing subscription. - const id = React.useMemo( - () => - typeof from === "string" ? from - : from === null ? null - : cache.identify(from), - [cache, from] - ); + // We calculate the cache id seperately because we don't want changes to non + // key fields in the `from` property to recreate the observable. If the cache + // identifier stays the same between renders, we want to reuse the existing + // subscription. + const ids = useDeepMemo(() => { + const fromArray = Array.isArray(from) ? from : [from]; - const stableOptions = useDeepMemo(() => ({ ...rest, from: id! }), [rest, id]); + const ids = fromArray.map((value) => + typeof value === "string" ? value + : value === null ? null + : cache.identify(value) + ); - // Since .next is async, we need to make sure that we - // get the correct diff on the next render given new diffOptions - const diff = React.useMemo(() => { - const { fragment, fragmentName, from, optimistic = true } = stableOptions; + return Array.isArray(from) ? ids : ids[0]; + }, [cache, from]); - if (from === null) { - return { - result: diffToResult({ - result: {}, - complete: false, - } as Cache.DiffResult), - }; - } - - const { cache } = client; - const diff = cache.diff({ - ...stableOptions, - returnPartialData: true, - id: from, - query: cache["getFragmentDoc"]( - client["transform"](fragment), - fragmentName - ), - optimistic, - }); + const stableOptions = useDeepMemo( + () => ({ ...rest, from: ids as any }), + [rest, ids] + ); - return { - result: diffToResult({ - ...diff, - result: client["queryManager"].maskFragment({ - fragment, - fragmentName, - // TODO: Revert to `diff.result` once `useFragment` supports `null` as - // valid return value - data: diff.result === null ? {} : diff.result, - }) as any, - }), - }; - }, [client, stableOptions]); + const observable = React.useMemo( + () => client.watchFragment(stableOptions), + [client, stableOptions] + ); - // Used for both getSnapshot and getServerSnapshot - const getSnapshot = React.useCallback(() => diff.result, [diff]); + // Unfortunately we forgot to update the use case of `from: null` on + // useFragment in 4.0 to match `useSuspenseFragment`. As such, we need to + // fallback to data: {} with complete: false when `from` is `null` to maintain + // backwards compatibility. We should plan to change this in v5. + const getSnapshot = React.useCallback( + () => (from === null ? nullResult : observable.getCurrentResult()), + [from, observable] + ); return useSyncExternalStore( React.useCallback( - (forceUpdate) => { + (update) => { let lastTimeout = 0; + const subscription = observable.subscribe({ + next: () => { + // If we get another update before we've re-rendered, bail out of + // the update and try again. This ensures that the relative timing + // between useQuery and useFragment stays roughly the same as + // fixed in https://github.com/apollographql/apollo-client/pull/11083 + clearTimeout(lastTimeout); + lastTimeout = setTimeout(update) as any; + }, + }); - const subscription = - stableOptions.from === null ? - null - : client.watchFragment(stableOptions).subscribe({ - next: (result) => { - // Avoid unnecessarily rerendering this hook for the initial result - // emitted from watchFragment which should be equal to - // `diff.result`. - if (equal(result, diff.result)) return; - diff.result = result; - // If we get another update before we've re-rendered, bail out of - // the update and try again. This ensures that the relative timing - // between useQuery and useFragment stays roughly the same as - // fixed in https://github.com/apollographql/apollo-client/pull/11083 - clearTimeout(lastTimeout); - lastTimeout = setTimeout(forceUpdate) as any; - }, - }); return () => { - subscription?.unsubscribe(); + subscription.unsubscribe(); clearTimeout(lastTimeout); }; }, - [client, stableOptions, diff] + [observable] ), getSnapshot, getSnapshot ); } -function diffToResult( - diff: Cache.DiffResult -): useFragment.Result { - const result = { - data: diff.result, - complete: !!diff.complete, - dataState: diff.complete ? "complete" : "partial", - } as useFragment.Result; // TODO: Remove assertion once useFragment returns null - - if (diff.missing) { - result.missing = diff.missing.missing; - } - - return result; -} +const nullResult = Object.freeze({ + data: {}, + dataState: "partial", + complete: false, +}) as useFragment.Result; diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index eb072497603..c529995e4fe 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -492,9 +492,11 @@ export function useLazyQuery< ...eagerMethods, ...result, client, + // eslint-disable-next-line react-hooks/refs previousData: previousDataRef.current, variables: observable.variables, observable, + // eslint-disable-next-line react-hooks/refs called: !!resultRef.current, }; }, [client, observableResult, eagerMethods, observable]); diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index e7378595202..14c23ec398f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -160,7 +160,18 @@ export declare namespace useMutation { TData = unknown, TVariables extends OperationVariables = OperationVariables, TCache extends ApolloCache = ApolloCache, - > = Options; + > = Options & { + /** + * {@inheritDoc @apollo/client!MutationOptionsDocumentation#context:member} + * + * @remarks + * When provided as a callback function, the function is called with the + * value of `context` provided to the `useMutation` hook. + */ + context?: + | DefaultContext + | ((hookContext: DefaultContext | undefined) => DefaultContext); + }; export namespace DocumentationTypes { /** {@inheritDoc @apollo/client/react!useMutation:function(1)} */ @@ -271,6 +282,10 @@ export function useMutation< const { options, mutation } = ref.current; const baseOptions = { ...options, mutation }; const client = executeOptions.client || ref.current.client; + const context = + typeof executeOptions.context === "function" ? + executeOptions.context(options?.context) + : executeOptions.context; if (!ref.current.result.loading && ref.current.isMounted) { setResult( @@ -285,7 +300,10 @@ export function useMutation< } const mutationId = ++ref.current.mutationId; - const clientOptions = mergeOptions(baseOptions, executeOptions as any); + const clientOptions = mergeOptions(baseOptions, { + ...executeOptions, + context, + } as any); return preventUnhandledRejection( client diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 20bb81a6842..41486dc38c9 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -392,7 +392,6 @@ export function useQuery< "use no memo"; return wrapHook( "useQuery", - // eslint-disable-next-line react-compiler/react-compiler useQuery_, useApolloClient(typeof options === "object" ? options.client : undefined) )(query, options); @@ -568,7 +567,6 @@ function useResult( return; } - // eslint-disable-next-line react-compiler/react-compiler resultData.variables = observable.variables; if (previous.data && !equal(previous.data, result.data)) { diff --git a/src/react/hooks/useQueryRefHandlers.ts b/src/react/hooks/useQueryRefHandlers.ts index 956a7fd97e7..dcc65b8bcdb 100644 --- a/src/react/hooks/useQueryRefHandlers.ts +++ b/src/react/hooks/useQueryRefHandlers.ts @@ -82,7 +82,6 @@ export function useQueryRefHandlers< return wrapHook( "useQueryRefHandlers", - // eslint-disable-next-line react-compiler/react-compiler useQueryRefHandlers_, clientOrObsQuery )(queryRef); diff --git a/src/react/hooks/useReadQuery.ts b/src/react/hooks/useReadQuery.ts index 47ee745f388..3bef6fdee11 100644 --- a/src/react/hooks/useReadQuery.ts +++ b/src/react/hooks/useReadQuery.ts @@ -102,12 +102,7 @@ export function useReadQuery< : undefined ) as ApolloClient | ObservableQuery; - return wrapHook( - "useReadQuery", - // eslint-disable-next-line react-compiler/react-compiler - useReadQuery_, - clientOrObsQuery - )(queryRef); + return wrapHook("useReadQuery", useReadQuery_, clientOrObsQuery)(queryRef); } function useReadQuery_["dataState"]>( diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index ca3386dda43..f3f0b5e32cb 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -372,7 +372,7 @@ export function useSubscription< } else { observable?.restart(); } - }, [optionsRef, recreateRef, observable]); + }, [observable, setObservable, optionsRef, recreateRef]); return React.useMemo(() => ({ ...ret, restart }), [ret, restart]); } diff --git a/src/react/hooks/useSuspenseFragment.ts b/src/react/hooks/useSuspenseFragment.ts index de6e0950858..ec3074ec475 100644 --- a/src/react/hooks/useSuspenseFragment.ts +++ b/src/react/hooks/useSuspenseFragment.ts @@ -5,12 +5,11 @@ import type { DataValue, DocumentNode, OperationVariables, - Reference, - StoreObject, TypedDocumentNode, } from "@apollo/client"; +import type { ApolloCache } from "@apollo/client/cache"; import { canonicalStringify } from "@apollo/client/cache"; -import type { FragmentType, MaybeMasked } from "@apollo/client/masking"; +import type { MaybeMasked } from "@apollo/client/masking"; import type { FragmentKey } from "@apollo/client/react/internal"; import { getSuspenseCache } from "@apollo/client/react/internal"; import type { @@ -20,16 +19,9 @@ import type { } from "@apollo/client/utilities/internal"; import { __use } from "./internal/__use.js"; -import { wrapHook } from "./internal/index.js"; +import { useDeepMemo, wrapHook } from "./internal/index.js"; import { useApolloClient } from "./useApolloClient.js"; -type From = - | StoreObject - | Reference - | FragmentType> - | string - | null; - export declare namespace useSuspenseFragment { import _self = useSuspenseFragment; export namespace Base { @@ -48,7 +40,16 @@ export declare namespace useSuspenseFragment { * `fragment` document then that fragment will be used. */ fragmentName?: string; - from: From; + + /** + * An object or array containing a `__typename` and primary key fields + * (such as `id`) identifying the entity object from which the fragment will + * be retrieved, or a `{ __ref: "..." }` reference, or a `string` ID (uncommon). + */ + from: + | useSuspenseFragment.FromValue + | Array>; + // Override this field to make it optional (default: true). optimistic?: boolean; /** @@ -77,6 +78,11 @@ export declare namespace useSuspenseFragment { } } + /** + * Acceptable values provided to the `from` option. + */ + export type FromValue = ApolloCache.WatchFragmentFromValue; + export interface Result { data: DataValue.Complete>; } @@ -97,18 +103,43 @@ export declare namespace useSuspenseFragment { } } -const NULL_PLACEHOLDER = [] as unknown as [ - FragmentKey, - Promise | null>, -]; - /** #TODO documentation */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array>>; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array>; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ export function useSuspenseFragment< TData, TVariables extends OperationVariables = OperationVariables, >( options: useSuspenseFragment.Options & { - from: NonNullable>; + from: NonNullable>; } ): useSuspenseFragment.Result; @@ -128,7 +159,7 @@ export function useSuspenseFragment< TVariables extends OperationVariables = OperationVariables, >( options: useSuspenseFragment.Options & { - from: From; + from: useSuspenseFragment.FromValue; } ): useSuspenseFragment.Result; @@ -149,7 +180,6 @@ export function useSuspenseFragment< "use no memo"; return wrapHook( "useSuspenseFragment", - // eslint-disable-next-line react-compiler/react-compiler useSuspenseFragment_, useApolloClient(typeof options === "object" ? options.client : undefined) )(options); @@ -165,36 +195,27 @@ function useSuspenseFragment_< const { from, variables } = options; const { cache } = client; - const id = React.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 } - ) - ); + const ids = useDeepMemo(() => { + return Array.isArray(from) ? + from.map((id) => toStringId(cache, id)) + : toStringId(cache, from); + }, [cache, from]); + const idString = React.useMemo( + () => (Array.isArray(ids) ? ids.join(",") : ids), + [ids] + ); + + const fragmentRef = getSuspenseCache(client).getFragmentRef( + [options.fragment, canonicalStringify(variables), idString], + client, + { ...options, variables: variables as TVariables, from: ids } + ); let [current, setPromise] = React.useState< [FragmentKey, Promise | null>] - >( - fragmentRef === null ? NULL_PLACEHOLDER : ( - [fragmentRef.key, fragmentRef.promise] - ) - ); + >([fragmentRef.key, fragmentRef.promise]); React.useEffect(() => { - if (fragmentRef === null) { - return; - } - const dispose = fragmentRef.retain(); const removeListener = fragmentRef.listen((promise) => { setPromise([fragmentRef.key, promise]); @@ -206,13 +227,10 @@ function useSuspenseFragment_< }; }, [fragmentRef]); - if (fragmentRef === null) { - return { data: null }; - } - if (current[0] !== fragmentRef.key) { - // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/immutability current[0] = fragmentRef.key; + // eslint-disable-next-line react-hooks/immutability current[1] = fragmentRef.promise; } @@ -220,3 +238,13 @@ function useSuspenseFragment_< return { data }; } + +function toStringId( + cache: ApolloCache, + from: useSuspenseFragment.FromValue +) { + return ( + typeof from === "string" ? from + : from === null ? null + : cache.identify(from)) as string | null; +} diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index b8ff4434a72..add5df35d31 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -349,7 +349,6 @@ export function useSuspenseQuery< "use no memo"; return wrapHook( "useSuspenseQuery", - // eslint-disable-next-line react-compiler/react-compiler useSuspenseQuery_, useApolloClient(typeof options === "object" ? options.client : undefined) )(query, options ?? ({} as any)); @@ -394,13 +393,15 @@ function useSuspenseQuery_< // This saves us a re-execution of the render function when a variable changed. if (current[0] !== queryRef.key) { - // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/immutability current[0] = queryRef.key; + // eslint-disable-next-line react-hooks/immutability current[1] = queryRef.promise; } let promise = current[1]; if (queryRef.didChangeOptions(watchQueryOptions)) { + // eslint-disable-next-line react-hooks/immutability current[1] = promise = queryRef.applyOptions(watchQueryOptions); } diff --git a/src/react/internal/cache/FragmentReference.ts b/src/react/internal/cache/FragmentReference.ts index f8d10e5bff5..1fa8135ba68 100644 --- a/src/react/internal/cache/FragmentReference.ts +++ b/src/react/internal/cache/FragmentReference.ts @@ -1,5 +1,5 @@ import { equal } from "@wry/equality"; -import type { Observable, Subscription } from "rxjs"; +import type { Subscription } from "rxjs"; import type { ApolloClient, OperationVariables } from "@apollo/client"; import type { MaybeMasked } from "@apollo/client/masking"; @@ -23,9 +23,7 @@ export class FragmentReference< TData = unknown, TVariables extends OperationVariables = OperationVariables, > { - public readonly observable: Observable< - ApolloClient.WatchFragmentResult - >; + public readonly observable: ApolloClient.ObservableFragment; public readonly key: FragmentKey = {}; public promise!: FragmentRefPromise>; @@ -44,7 +42,7 @@ export class FragmentReference< TData, TVariables > & { - from: string; + from: string | null | Array; }, options: FragmentReferenceOptions ) { @@ -58,7 +56,7 @@ export class FragmentReference< this.onDispose = options.onDispose; } - const diff = this.getDiff(client, watchFragmentOptions); + const result = this.observable.getCurrentResult(); // Start a timer that will automatically dispose of the query if the // suspended resource does not use this fragmentRef in the given time. This @@ -74,8 +72,8 @@ export class FragmentReference< }; this.promise = - diff.complete ? - createFulfilledPromise(diff.result) + result.complete ? + createFulfilledPromise(result.data) : this.createPendingPromise(); this.subscribeToFragment(); @@ -130,7 +128,7 @@ export class FragmentReference< this.subscription.add(this.onDispose); } - private handleNext(result: ApolloClient.WatchFragmentResult) { + private handleNext(result: ApolloClient.WatchFragmentResult) { switch (this.promise.status) { case "pending": { if (result.complete) { @@ -175,34 +173,4 @@ export class FragmentReference< }) ); } - - private getDiff( - client: ApolloClient, - options: ApolloClient.WatchFragmentOptions & { - from: string; - } - ) { - const { cache } = client; - const { from, fragment, fragmentName } = options; - - const diff = cache.diff({ - ...options, - query: cache["getFragmentDoc"]( - client["transform"](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/QueryReference.ts b/src/react/internal/cache/QueryReference.ts index cef2a7f6ec2..1fe6d55af53 100644 --- a/src/react/internal/cache/QueryReference.ts +++ b/src/react/internal/cache/QueryReference.ts @@ -1,3 +1,8 @@ +import type { + DocumentTypeDecoration, + ResultOf, + VariablesOf, +} from "@graphql-typed-document-node/core"; import { equal } from "@wry/equality"; import type { Subscription } from "rxjs"; import { filter } from "rxjs"; @@ -48,6 +53,15 @@ export interface QueryRef< [QUERY_REF_BRAND]?(variables: TVariables): { data: TData; states: TStates }; } +export declare namespace QueryRef { + export type ForQuery< + Document extends DocumentTypeDecoration, + TStates extends DataState>["dataState"] = + | "complete" + | "streaming", + > = QueryRef, VariablesOf, TStates>; +} + /** * @internal * For usage in internal helpers only. @@ -186,6 +200,7 @@ export class InternalQueryReference< public promise!: QueryRefPromise; + private queue: QueryRefPromise | undefined; private subscription!: Subscription; private listeners = new Set>(); private autoDisposeTimeoutId?: NodeJS.Timeout; @@ -335,6 +350,11 @@ export class InternalQueryReference< listen(listener: Listener) { this.listeners.add(listener); + if (this.queue) { + this.deliver(this.queue); + this.queue = undefined; + } + return () => { this.listeners.delete(listener); }; @@ -412,6 +432,18 @@ export class InternalQueryReference< } private deliver(promise: QueryRefPromise) { + // Maintain a queue of the last item we tried to deliver so that we can + // deliver it as soon as we get the first listener. This helps in cases such + // as `@stream` where React may render a component and incremental results + // are loaded in between when the component renders and effects are run. If + // effects are run after the incremntal chunks are delivered, we'll have + // rendered a stale value. The queue ensures we can deliver the most + // up-to-date value as soon as the component is ready to listen for new + // values. + if (this.listeners.size === 0) { + this.queue = promise; + } + this.listeners.forEach((listener) => listener(promise)); } diff --git a/src/react/internal/cache/SuspenseCache.ts b/src/react/internal/cache/SuspenseCache.ts index b42aa22cfb7..4e7ea9332f2 100644 --- a/src/react/internal/cache/SuspenseCache.ts +++ b/src/react/internal/cache/SuspenseCache.ts @@ -60,7 +60,7 @@ export class SuspenseCache { cacheKey: FragmentCacheKey, client: ApolloClient, options: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | null | Array; } ) { const ref = this.fragmentRefs.lookupArray(cacheKey) as { diff --git a/src/react/internal/cache/__tests__/QueryReference.test.tsx b/src/react/internal/cache/__tests__/QueryReference.test.tsx index 341dc984e9c..422c215efc1 100644 --- a/src/react/internal/cache/__tests__/QueryReference.test.tsx +++ b/src/react/internal/cache/__tests__/QueryReference.test.tsx @@ -1,7 +1,12 @@ +import { expectTypeOf } from "expect-type"; import React from "react"; import { of } from "rxjs"; -import type { DataState, OperationVariables } from "@apollo/client"; +import type { + DataState, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client"; import { InternalQueryReference } from "@apollo/client/react/internal"; import { setupSimpleCase } from "@apollo/client/testing/internal"; @@ -240,4 +245,28 @@ test.skip("type tests", () => { ; } }); + + test("QueryRef.ForQuery", () => { + const ANY: any = {}; + + interface Data { + foo: string; + } + type Vars = { + bar: string; + }; + const query: TypedDocumentNode = ANY; + + expectTypeOf>().toEqualTypeOf< + QueryRef + >(); + + expectTypeOf>().toEqualTypeOf< + QueryRef + >(); + + expectTypeOf< + QueryRef.ForQuery + >().toEqualTypeOf>(); + }); }); diff --git a/src/react/internal/cache/types.ts b/src/react/internal/cache/types.ts index a163431ad9d..2aba1e401e6 100644 --- a/src/react/internal/cache/types.ts +++ b/src/react/internal/cache/types.ts @@ -7,9 +7,9 @@ export type CacheKey = [ ]; export type FragmentCacheKey = [ - cacheId: string, fragment: DocumentNode, stringifiedVariables: string, + cacheId: string | null, ]; export interface QueryKey { diff --git a/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx index f05defde306..023f3e9ffc3 100644 --- a/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx +++ b/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx @@ -25,7 +25,6 @@ import { InMemoryCache, NetworkStatus, } from "@apollo/client"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { PreloadedQueryRef, QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -33,7 +32,7 @@ import { useReadQuery, } from "@apollo/client/react"; import { unwrapQueryRef } from "@apollo/client/react/internal"; -import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; +import { MockLink } from "@apollo/client/testing"; import type { MaskedVariablesCaseData, SimpleCaseData, @@ -41,7 +40,6 @@ import type { } from "@apollo/client/testing/internal"; import { createClientWrapper, - markAsStreaming, renderHookAsync, setupMaskedVariablesCase, setupSimpleCase, @@ -1806,97 +1804,6 @@ test("does not suspend and returns partial data when `returnPartialData` is `tru } }); -test("suspends deferred queries until initial chunk loads then rerenders with deferred data", async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - incrementalHandler: new Defer20220824Handler(), - }); - - const preloadQuery = createQueryPreloader(client); - const queryRef = preloadQuery(query); - - using _disabledAct = disableActEnvironment(); - const { renderStream } = await renderDefaultTestApp({ client, queryRef }); - - { - const { renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["App", "SuspenseFallback"]); - } - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); - expect(snapshot.result).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } -}); - test("masks result when dataMasking is `true`", async () => { const { query, mocks } = setupMaskedVariablesCase(); const client = new ApolloClient({ diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx new file mode 100644 index 00000000000..024033c91ff --- /dev/null +++ b/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx @@ -0,0 +1,169 @@ +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { DataState } from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { + ApolloProvider, + createQueryPreloader, + useReadQuery, +} from "@apollo/client/react"; +import { + markAsStreaming, + mockDefer20220824, +} from "@apollo/client/testing/internal"; + +async function renderDefaultTestApp< + TData, + TStates extends DataState["dataState"] = "complete" | "streaming", +>({ + client, + queryRef, +}: { + client: ApolloClient; + queryRef: QueryRef; +}) { + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + error: null as Error | null, + }, + }); + + function ReadQueryHook() { + useTrackRenders({ name: "ReadQueryHook" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + return

Loading

; + } + + function ErrorFallback({ error }: { error: Error }) { + useTrackRenders({ name: "ErrorFallback" }); + renderStream.mergeSnapshot({ error }); + + return null; + } + + function App() { + useTrackRenders({ name: "App" }); + + return ( + + }> + + + + ); + } + + const utils = await renderStream.render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + function rerender() { + return utils.rerender(); + } + + return { ...utils, rerender, renderStream }; +} + +test("suspends deferred queries until initial chunk loads then rerenders with deferred data", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/deferGraphQL17Alpha9.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..5917f770217 --- /dev/null +++ b/src/react/query-preloader/__tests__/createQueryPreloader/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,171 @@ +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { DataState } from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { + ApolloProvider, + createQueryPreloader, + useReadQuery, +} from "@apollo/client/react"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, +} from "@apollo/client/testing/internal"; + +async function renderDefaultTestApp< + TData, + TStates extends DataState["dataState"] = "complete" | "streaming", +>({ + client, + queryRef, +}: { + client: ApolloClient; + queryRef: QueryRef; +}) { + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + error: null as Error | null, + }, + }); + + function ReadQueryHook() { + useTrackRenders({ name: "ReadQueryHook" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + return

Loading

; + } + + function ErrorFallback({ error }: { error: Error }) { + useTrackRenders({ name: "ErrorFallback" }); + renderStream.mergeSnapshot({ error }); + + return null; + } + + function App() { + useTrackRenders({ name: "App" }); + + return ( + + }> + + + + ); + } + + const utils = await renderStream.render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + function rerender() { + return utils.rerender(); + } + + return { ...utils, rerender, renderStream }; +} + +test("suspends deferred queries until initial chunk loads then rerenders with deferred data", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); diff --git a/src/react/ssr/useSSRQuery.ts b/src/react/ssr/useSSRQuery.ts index 8bfc498d444..2fdc928a200 100644 --- a/src/react/ssr/useSSRQuery.ts +++ b/src/react/ssr/useSSRQuery.ts @@ -17,10 +17,12 @@ const skipStandbyResult: ObservableQuery.Result = maybeDeepFreeze({ }); export const useSSRQuery = function ( + // eslint-disable-next-line react-hooks/unsupported-syntax this: PrerenderStaticInternalContext, query: DocumentNode, options: useQuery.Options = {} ): useQuery.Result { + "use no memo"; function notAllowed(): never { throw new Error("This method cannot be called during SSR."); } diff --git a/src/testing/internal/asyncIterableSubject.ts b/src/testing/internal/asyncIterableSubject.ts new file mode 100644 index 00000000000..9085dd149b4 --- /dev/null +++ b/src/testing/internal/asyncIterableSubject.ts @@ -0,0 +1,16 @@ +import { Subject } from "rxjs"; + +export function asyncIterableSubject() { + const subject = new Subject(); + + const stream = new ReadableStream({ + start: (controller) => { + subject.subscribe({ + next: (value) => controller.enqueue(value), + complete: () => controller.close(), + }); + }, + }); + + return { subject, stream }; +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts new file mode 100644 index 00000000000..eeba9cde67c --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts @@ -0,0 +1,36 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha2"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha2"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha2( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {} +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts new file mode 100644 index 00000000000..8285297367b --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts @@ -0,0 +1,38 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha9"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha9"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha9( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution?: boolean +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index 1ebe8234c9c..de686fc955d 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,12 +23,15 @@ export { } from "./scenarios/index.js"; export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.js"; +export { asyncIterableSubject } from "./asyncIterableSubject.js"; +export { executeSchemaGraphQL17Alpha2 } from "./incremental/executeSchemaGraphQL17Alpha2.js"; +export { executeSchemaGraphQL17Alpha9 } from "./incremental/executeSchemaGraphQL17Alpha9.js"; +export { promiseWithResolvers } from "./promiseWithResolvers.js"; export { renderAsync } from "./rtl/renderAsync.js"; export { renderHookAsync } from "./rtl/renderHookAsync.js"; -export { - mockDeferStream, - mockMultipartSubscriptionStream, -} from "./incremental.js"; +export { mockDefer20220824 } from "./multipart/mockDefer20220824.js"; +export { mockDeferStreamGraphQL17Alpha9 } from "./multipart/mockDeferStreamGraphql17Alpha9.js"; +export { mockMultipartSubscriptionStream } from "./multipart/mockMultipartSubscriptionStream.js"; export { resetApolloContext } from "./resetApolloContext.js"; export { createOperationWithDefaultContext, @@ -36,3 +39,6 @@ export { } from "./link.js"; export { markAsStreaming } from "./markAsStreaming.js"; export { wait } from "./wait.js"; + +export { friendListSchemaGraphQL17Alpha2 } from "./schemas/friendList.graphql17Alpha2.js"; +export { friendListSchemaGraphQL17Alpha9 } from "./schemas/friendList.graphql17Alpha9.js"; diff --git a/src/testing/internal/multipart/mockDefer20220824.ts b/src/testing/internal/multipart/mockDefer20220824.ts new file mode 100644 index 00000000000..67afe6636d7 --- /dev/null +++ b/src/testing/internal/multipart/mockDefer20220824.ts @@ -0,0 +1,47 @@ +import type { + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLFormattedError, +} from "graphql-17-alpha2"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockDefer20220824< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + >({ + responseHeaders: new Headers({ + "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', + }), + }); + return { + httpLink, + enqueueInitialChunk( + chunk: FormattedInitialIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueSubsequentChunk( + chunk: FormattedSubsequentIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueErrorChunk(errors: GraphQLFormattedError[]) { + enqueue( + { + hasNext: true, + incremental: [ + { + errors, + }, + ], + }, + true + ); + }, + }; +} diff --git a/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts b/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts new file mode 100644 index 00000000000..9532b1b57eb --- /dev/null +++ b/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts @@ -0,0 +1,33 @@ +import type { + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha9"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockDeferStreamGraphQL17Alpha9< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + >({ + responseHeaders: new Headers({ + "Content-Type": 'multipart/mixed; boundary="-"', + }), + }); + return { + httpLink, + enqueueInitialChunk( + chunk: FormattedInitialIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueSubsequentChunk( + chunk: FormattedSubsequentIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + }; +} diff --git a/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts b/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts new file mode 100644 index 00000000000..73e29c1a9cc --- /dev/null +++ b/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts @@ -0,0 +1,36 @@ +import type { ApolloPayloadResult } from "@apollo/client"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockMultipartSubscriptionStream< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + ApolloPayloadResult + >({ + responseHeaders: new Headers({ + "Content-Type": "multipart/mixed", + }), + }); + + enqueueHeartbeat(); + + function enqueueHeartbeat() { + enqueue({} as any, true); + } + + return { + httpLink, + enqueueHeartbeat, + enqueuePayloadResult( + payload: ApolloPayloadResult["payload"], + hasNext = true + ) { + enqueue({ payload }, hasNext); + }, + enqueueProtocolErrors(errors: ApolloPayloadResult["errors"]) { + enqueue({ payload: null, errors }, false); + }, + }; +} diff --git a/src/testing/internal/incremental.ts b/src/testing/internal/multipart/utils.ts similarity index 53% rename from src/testing/internal/incremental.ts rename to src/testing/internal/multipart/utils.ts index a457b2189ff..70b9bae52d2 100644 --- a/src/testing/internal/incremental.ts +++ b/src/testing/internal/multipart/utils.ts @@ -4,18 +4,11 @@ import { TransformStream, } from "node:stream/web"; -import type { - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, - GraphQLFormattedError, -} from "graphql-17-alpha2"; - -import type { ApolloPayloadResult } from "@apollo/client"; import { HttpLink } from "@apollo/client/link/http"; const hasNextSymbol = Symbol("hasNext"); -function mockIncrementalStream({ +export function mockMultipartStream({ responseHeaders, }: { responseHeaders: Headers; @@ -108,76 +101,3 @@ function mockIncrementalStream({ close, }; } - -export function mockDeferStream< - TData = Record, - TExtensions = Record, ->() { - const { httpLink, enqueue } = mockIncrementalStream< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - >({ - responseHeaders: new Headers({ - "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', - }), - }); - return { - httpLink, - enqueueInitialChunk( - chunk: FormattedInitialIncrementalExecutionResult - ) { - enqueue(chunk, chunk.hasNext); - }, - enqueueSubsequentChunk( - chunk: FormattedSubsequentIncrementalExecutionResult - ) { - enqueue(chunk, chunk.hasNext); - }, - enqueueErrorChunk(errors: GraphQLFormattedError[]) { - enqueue( - { - hasNext: true, - incremental: [ - { - errors, - }, - ], - }, - true - ); - }, - }; -} - -export function mockMultipartSubscriptionStream< - TData = Record, - TExtensions = Record, ->() { - const { httpLink, enqueue } = mockIncrementalStream< - ApolloPayloadResult - >({ - responseHeaders: new Headers({ - "Content-Type": "multipart/mixed", - }), - }); - - enqueueHeartbeat(); - - function enqueueHeartbeat() { - enqueue({} as any, true); - } - - return { - httpLink, - enqueueHeartbeat, - enqueuePayloadResult( - payload: ApolloPayloadResult["payload"], - hasNext = true - ) { - enqueue({ payload }, hasNext); - }, - enqueueProtocolErrors(errors: ApolloPayloadResult["errors"]) { - enqueue({ payload: null, errors }, false); - }, - }; -} diff --git a/src/testing/internal/promiseWithResolvers.ts b/src/testing/internal/promiseWithResolvers.ts new file mode 100644 index 00000000000..68283719b04 --- /dev/null +++ b/src/testing/internal/promiseWithResolvers.ts @@ -0,0 +1,15 @@ +export function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | Promise) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | Promise) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha2.ts b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts new file mode 100644 index 00000000000..17d59da59a4 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha2"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha2 = new GraphQLSchema({ query }); diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha9.ts b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts new file mode 100644 index 00000000000..4f774afab13 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha9 = new GraphQLSchema({ query }); diff --git a/src/testing/matchers/index.d.ts b/src/testing/matchers/index.d.ts index e0b73018afd..70127ddce9f 100644 --- a/src/testing/matchers/index.d.ts +++ b/src/testing/matchers/index.d.ts @@ -8,6 +8,7 @@ import type { MatcherHintOptions } from "jest-matcher-utils"; import type { ApolloClient, DocumentNode, + InMemoryCache, ObservableQuery, OperationVariables, } from "@apollo/client"; @@ -17,6 +18,7 @@ import type { ObservableStream } from "../internal/index.js"; import { NextRenderOptions } from "../internal/index.js"; import type { TakeOptions } from "../internal/ObservableStream.js"; +import type { KeyOptions } from "./toHaveFragmentWatches.ts"; import type { CommonStream, ToEmitSimilarValueOptions, @@ -56,6 +58,13 @@ interface ApolloCustomMatchers { */ toMatchDocument(document: DocumentNode): R; + toHaveFragmentWatchesOn: T extends ApolloClient ? + (fragment: DocumentNode, keyOptions: Array) => R + : { error: "matcher needs to be called on an ApolloClient instance" }; + + toHaveNumWatches: T extends InMemoryCache ? (size: number) => R + : { error: "matcher needs to be called on an InMemoryCache instance" }; + /** * Used to determine if the Suspense cache has a cache entry. */ diff --git a/src/testing/matchers/index.ts b/src/testing/matchers/index.ts index 0ba1ebc38b2..6c79a045ca3 100644 --- a/src/testing/matchers/index.ts +++ b/src/testing/matchers/index.ts @@ -8,6 +8,8 @@ import { toEmitAnything } from "./toEmitAnything.js"; import { toEmitError } from "./toEmitError.js"; import { toEmitNext } from "./toEmitNext.js"; import { toEmitTypedValue } from "./toEmitTypedValue.js"; +import { toHaveFragmentWatchesOn } from "./toHaveFragmentWatchesOn.js"; +import { toHaveNumWatches } from "./toHaveNumWatches.js"; import { toHaveSuspenseCacheEntryUsing } from "./toHaveSuspenseCacheEntryUsing.js"; import { toMatchDocument } from "./toMatchDocument.js"; import { @@ -24,6 +26,8 @@ expect.extend({ toEmitNext, toEmitTypedValue, toBeDisposed, + toHaveFragmentWatchesOn, + toHaveNumWatches, toHaveSuspenseCacheEntryUsing, toMatchDocument, toBeGarbageCollected, diff --git a/src/testing/matchers/toHaveFragmentWatchesOn.ts b/src/testing/matchers/toHaveFragmentWatchesOn.ts new file mode 100644 index 00000000000..6ffb7c6e49a --- /dev/null +++ b/src/testing/matchers/toHaveFragmentWatchesOn.ts @@ -0,0 +1,99 @@ +import { iterableEquality } from "@jest/expect-utils"; +import type { Trie } from "@wry/trie"; +import type { MatcherFunction } from "expect"; + +import type { + ApolloClient, + Cache, + DocumentNode, + InMemoryCache, +} from "@apollo/client"; + +export type KeyOptions = Pick< + Cache.WatchOptions, + "id" | "optimistic" | "variables" +>; + +export const toHaveFragmentWatchesOn: MatcherFunction< + [fragment: DocumentNode, keyOptions: Array] +> = function (_client, fragment, keyOptions) { + const hint = this.utils.matcherHint( + "toHaveFragmentWatches", + "client", + "keyOptions", + { + isNot: this.isNot, + } + ); + const client = _client as ApolloClient; + const cache = client.cache as InMemoryCache; + + function getFragmentWatches() { + // testing implementation detail to ensure cache.fragmentWatches also cleans up + const watchedItems: Trie | undefined = cache["fragmentWatches"][ + "weak" + ].get( + client.cache["getFragmentDoc"]( + client["transform"](fragment, true), + undefined + ) + ); + function* iterateStrongTrieChildren( + trie: Trie | undefined, + path: any[] + ): Generator { + if (!trie) return; + if (trie["data"]) { + yield path; + } + if (trie["strong"]) { + for (const [key, value] of Array.from( + (trie["strong"] as Map | undefined>)?.entries() + )) { + yield* iterateStrongTrieChildren(value, path.concat(key)); + } + } + } + + return Array.from(iterateStrongTrieChildren(watchedItems, [])); + } + + const watches = getFragmentWatches().map((cacheKey) => { + if (cacheKey.length > 1) { + throw new Error( + "The `watchFragment` watcher cache key has changed. Please update the toHaveFragmentWatchesOn matcher." + ); + } + + return JSON.parse(cacheKey[0]); + }); + + const pass = this.equals(watches, keyOptions, [ + ...this.customTesters, + iterableEquality, + ]); + + return { + pass, + message: () => { + if (pass) { + return ( + hint + + "\n\nExpected client not to have fragment watches equal to expected but it did." + ); + } + + return ( + hint + + "\n\n" + + this.utils.printDiffOrStringify( + keyOptions, + watches, + "Expected", + "Received", + true + ) + ); + }, + }; +}; diff --git a/src/testing/matchers/toHaveNumWatches.ts b/src/testing/matchers/toHaveNumWatches.ts new file mode 100644 index 00000000000..fd502f9e21c --- /dev/null +++ b/src/testing/matchers/toHaveNumWatches.ts @@ -0,0 +1,35 @@ +import type { MatcherFunction } from "expect"; + +import type { InMemoryCache } from "@apollo/client"; + +export const toHaveNumWatches: MatcherFunction<[size: number]> = function ( + _cache, + size +) { + const hint = this.utils.matcherHint("toHaveNumWatches", "cache", "size", { + isNot: this.isNot, + }); + const cache = _cache as InMemoryCache; + const watchSize = cache["watches"].size; + const watchIds = Array.from(cache["watches"].values()).map( + (watch) => `'${watch.id ?? "ROOT_QUERY"}'` + ); + const pass = watchSize === size; + + const plural = (size: number) => (size === 1 ? "watch" : "watches"); + + return { + pass, + message: () => { + return `${hint}\n\nExpected cache ${ + this.isNot ? "not " : "" + }to have ${this.utils.printExpected(size)} ${plural( + size + )} but instead it had ${this.utils.printReceived(watchSize)} ${plural( + watchSize + )}.\n\nWatches: ${this.utils.printReceived( + "[" + watchIds.join(", ") + "]" + )}`; + }, + }; +}; diff --git a/src/utilities/DeepPartial.ts b/src/utilities/DeepPartial.ts index 0ed92959eea..7fcb8d87b7e 100644 --- a/src/utilities/DeepPartial.ts +++ b/src/utilities/DeepPartial.ts @@ -37,8 +37,8 @@ export type DeepPartial = T // Test for non-tuples ) ? readonly TItem[] extends T ? - ReadonlyArray> - : Array> + ReadonlyArray> + : Array> : DeepPartialObject : DeepPartialObject : unknown; diff --git a/src/utilities/internal/DeepMerger.ts b/src/utilities/internal/DeepMerger.ts index ebbcce20660..91d28780387 100644 --- a/src/utilities/internal/DeepMerger.ts +++ b/src/utilities/internal/DeepMerger.ts @@ -19,12 +19,38 @@ const defaultReconciler: ReconcilerFunction = function ( }; /** @internal */ -export class DeepMerger { +export declare namespace DeepMerger { + export interface Options { + arrayMerge?: DeepMerger.ArrayMergeStrategy; + } + + export type ArrayMergeStrategy = + // Truncate the target array to the source length, then deep merge the array + // items at the same index + | "truncate" + // Combine arrays and deep merge array items for items at the same index. + // This is the default + | "combine"; +} + +/** @internal */ +export class DeepMerger { constructor( - private reconciler: ReconcilerFunction = defaultReconciler as any as ReconcilerFunction + private reconciler: ReconcilerFunction = defaultReconciler as any as ReconcilerFunction, + private options: DeepMerger.Options = {} ) {} public merge(target: any, source: any, ...context: TContextArgs): any { + if ( + Array.isArray(target) && + Array.isArray(source) && + this.options.arrayMerge === "truncate" && + target.length > source.length + ) { + target = target.slice(0, source.length); + this.pastCopies.add(target); + } + if (isNonNullObject(source) && isNonNullObject(target)) { Object.keys(source).forEach((sourceKey) => { if (hasOwnProperty.call(target, sourceKey)) { diff --git a/src/utilities/internal/__tests__/DeepMerger.test.ts b/src/utilities/internal/__tests__/DeepMerger.test.ts index 88d6b05a2ba..01b0361ac83 100644 --- a/src/utilities/internal/__tests__/DeepMerger.test.ts +++ b/src/utilities/internal/__tests__/DeepMerger.test.ts @@ -94,3 +94,34 @@ test("provides optional context to reconciler function", function () { expect(typicalContextValues[0]).toBe(contextObject); expect(typicalContextValues[1]).toBe(contextObject); }); + +test("deep merges each array item keeping length by default", () => { + const target = [{ a: 1, b: { c: 2 } }, { e: 5 }]; + const source = [{ a: 2, b: { c: 2, d: 3 } }]; + + const result = new DeepMerger().merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2, d: 3 } }, { e: 5 }]); +}); + +test("deep merges each array item and truncates source to target length when using truncate arrayMerge", () => { + const target = [{ a: 1, b: { c: 2 } }, { e: 5 }]; + const source = [{ a: 2, b: { c: 2, d: 3 } }]; + + const result = new DeepMerger(undefined, { + arrayMerge: "truncate", + }).merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2, d: 3 } }]); +}); + +test("maintains source length when using truncate arrayMerge when source is longer than target length", () => { + const target = [{ a: 1, b: { c: 2 } }]; + const source = [{ a: 2 }, { e: 2 }]; + + const result = new DeepMerger(undefined, { + arrayMerge: "truncate", + }).merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2 } }, { e: 2 }]); +}); diff --git a/src/utilities/internal/combineLatestBatched.ts b/src/utilities/internal/combineLatestBatched.ts new file mode 100644 index 00000000000..c00ec720bb0 --- /dev/null +++ b/src/utilities/internal/combineLatestBatched.ts @@ -0,0 +1,80 @@ +import { EMPTY, Observable } from "rxjs"; + +/** + * Like `combineLatest` but with some differences: + * + * - It only works on arrays as an input + * - Batches updates to each array index that contains a referentially equal + * observable + * - Doesn't allow for custom scheduler + * - Expects array of constructed observables instead of `Array` + */ +export function combineLatestBatched( + observables: Array & { dirty?: boolean }> +) { + if (observables.length === 0) { + return EMPTY; + } + + return new Observable>((observer) => { + const { length } = observables; + // Keeps track of current values for each observable + const values: T[] = new Array(length); + // Used to batch an update each item in the array that share an observable + // so that they can be emitted together. + const indexesByObservable = new Map, Set>(); + + observables.forEach((source, idx) => { + if (!indexesByObservable.has(source)) { + indexesByObservable.set(source, new Set()); + } + + indexesByObservable.get(source)!.add(idx); + }); + + // Track the number of active subscriptions so we know when to complete this + // observable + let active = indexesByObservable.size; + // Track how many observables are left to emit their first value + let remainingFirstValues = indexesByObservable.size; + + let currentBatch: Set> | undefined; + + // Subscribe to each unique observable instead of the raw source array of + // observables since we want at most 1-subscription per unique observable. + // This ensures an update can write to multiple indexes before emitting the + // result. + indexesByObservable.forEach((indexes, source) => { + let hasFirstValue = false; + const subscription = source.subscribe({ + next: (value) => { + indexes.forEach((idx) => (values[idx] = value)); + + if (!hasFirstValue) { + hasFirstValue = true; + remainingFirstValues--; + } + + if (!remainingFirstValues) { + currentBatch ||= new Set(observables.filter((obs) => obs.dirty)); + currentBatch.delete(source); + if (!currentBatch.size) { + observer.next(values.slice()); + currentBatch = undefined; + } + } + }, + complete: () => { + active--; + + if (!active) { + observer.complete(); + } + }, + error: observer.error.bind(observer), + }); + + observer.add(subscription); + }); + }); +} diff --git a/src/utilities/internal/getStoreKeyName.ts b/src/utilities/internal/getStoreKeyName.ts index e8056c246af..0b63ebcce30 100644 --- a/src/utilities/internal/getStoreKeyName.ts +++ b/src/utilities/internal/getStoreKeyName.ts @@ -14,6 +14,7 @@ const KNOWN_DIRECTIVES: string[] = [ "rest", "export", "nonreactive", + "stream", ]; // Default stable JSON.stringify implementation used by getStoreKeyName. Can be diff --git a/src/utilities/internal/index.ts b/src/utilities/internal/index.ts index 47add2520ec..e551ad4fb0d 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -17,6 +17,7 @@ export { argumentsObjectFromField } from "./argumentsObjectFromField.js"; export { canUseDOM } from "./canUseDOM.js"; export { checkDocument } from "./checkDocument.js"; export { cloneDeep } from "./cloneDeep.js"; +export { combineLatestBatched } from "./combineLatestBatched.js"; export { compact } from "./compact.js"; export { createFragmentMap } from "./createFragmentMap.js"; export { createFulfilledPromise } from "./createFulfilledPromise.js"; diff --git a/tsconfig.json b/tsconfig.json index 7bbdcf7fdcc..578691cbab1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "experimentalDecorators": true, "outDir": "./dist", "rootDir": "./src", - "lib": ["DOM", "ES2023"], + "lib": ["DOM", "dom.asyncIterable", "ES2023"], "types": [ "jest", "node",