diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index d65152cc464..6189e8504c2 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -9,6 +9,7 @@ import type { AsStoreObject } from '@apollo/client/utilities'; import { canonicalStringify } from '@apollo/client/utilities'; import type { DataValue } from '@apollo/client'; import type { DeepPartial } from '@apollo/client/utilities'; +import type { DeferInfoTrie } from '@apollo/client/utilities/internal'; import type { DocumentNode } from 'graphql'; import type { DocumentNode as DocumentNode_2 } from '@apollo/client'; import type { ExtensionsWithStreamInfo } from '@apollo/client/utilities/internal'; @@ -21,6 +22,7 @@ import { getApolloCacheMemoryInternals } from '@apollo/client/utilities/internal import type { GetDataState } from '@apollo/client'; import { getInMemoryCacheMemoryInternals } from '@apollo/client/utilities/internal'; import type { GraphQLScalarType } from 'graphql'; +import { handleIncrementalSymbol } from '@apollo/client/utilities/internal'; import type { Incremental } from '@apollo/client/incremental'; import type { InlineFragmentNode } from 'graphql'; import type { IsAny } from '@apollo/client/utilities/internal'; @@ -35,6 +37,7 @@ import type { RemoveIndexSignature } from '@apollo/client/utilities/internal'; import type { SelectionSetNode } from 'graphql'; import type { StoreObject } from '@apollo/client/utilities'; import type { StoreValue } from '@apollo/client/utilities'; +import type { StreamInfoTrie } from '@apollo/client/utilities/internal'; import { Trie } from '@wry/trie'; import type { TypedDocumentNode } from '@apollo/client'; import type { TypeOverrides } from '@apollo/client'; @@ -226,6 +229,32 @@ namespace Cache_2 { cache: infer TCache; } ? TCache extends ApolloCache ? TCache : "The cache type declared in TypeOverrides does not extend `ApolloCache` and cannot be used with Apollo Client. See https://www.apollographql.com/docs/react/data/typescript#declaring-the-cache-type." : ApolloCache; // (undocumented) + type InternalDiffResultWithDataState = { + result: DataValue.Complete; + complete: true; + missing?: never; + dataState: "complete"; + fromOptimisticTransaction?: boolean; + } | { + result: DataValue.Streaming; + complete: false; + missing?: never; + dataState: "streaming"; + fromOptimisticTransaction?: boolean; + } | { + result: DataValue.Partial; + complete: false; + missing?: MissingFieldError; + dataState: "partial"; + fromOptimisticTransaction?: boolean; + } | { + result: null; + complete: false; + missing?: MissingFieldError; + dataState: "empty"; + fromOptimisticTransaction?: boolean; + }; + // (undocumented) interface ModifyOptions = Record> { // (undocumented) broadcast?: boolean; @@ -370,9 +399,18 @@ interface DeleteModifier { // @public (undocumented) const _deleteModifier: unique symbol; +// @internal @deprecated (undocumented) +export interface DiffIncrementalInfo { + // (undocumented) + deferInfo?: DeferInfoTrie; + // (undocumented) + streamInfo?: StreamInfoTrie; +} + // @public (undocumented) export type DiffQueryAgainstStoreOptions = ReadQueryOptions & { returnPartialData?: boolean; + [handleIncrementalSymbol]?: true | DiffIncrementalInfo; }; // @public (undocumented) @@ -592,6 +630,8 @@ export namespace InMemoryCache { // @public (undocumented) export class InMemoryCache extends ApolloCache { + // (undocumented) + readonly [handleIncrementalSymbol] = true; constructor(...args: {} extends InMemoryCache.ScalarsOption ? [ config?: InMemoryCacheConfig ] : [config: InMemoryCacheConfig]); @@ -605,7 +645,11 @@ export class InMemoryCache extends ApolloCache { // (undocumented) protected config: InMemoryCacheConfig; // (undocumented) - diff(options: Cache_2.DiffOptions): Cache_2.DiffResult; + diff(query: Cache_2.DiffOptions & { + [handleIncrementalSymbol]: true | DiffIncrementalInfo; + }): Cache_2.InternalDiffResultWithDataState; + // (undocumented) + diff(query: Cache_2.DiffOptions): Cache_2.DiffResult; // (undocumented) evict(options: Cache_2.EvictOptions): boolean; // (undocumented) @@ -1109,9 +1153,11 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // +// src/cache/inmemory/inMemoryCache.ts:461:7 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal // src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:179:3 - (ae-forgotten-export) The symbol "ScalarNames" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:145:3 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal +// src/cache/inmemory/types.ts:149:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-incremental.api.md b/.api-reports/api-report-incremental.api.md index 80f4e40df9f..e61cd6dc389 100644 --- a/.api-reports/api-report-incremental.api.md +++ b/.api-reports/api-report-incremental.api.md @@ -74,9 +74,7 @@ class Defer20220824Handler implements Incremental.Handler>(_: { - query: DocumentNode; - }): DeferRequest; + startRequest>(_: Incremental.StartRequestOptions): DeferRequest; } export { Defer20220824Handler } export { Defer20220824Handler as GraphQL17Alpha2Handler } @@ -180,9 +178,7 @@ export class GraphQL17Alpha9Handler implements Incremental.Handler(_: { - query: DocumentNode; - }): IncrementalRequest; + startRequest(_: Incremental.StartRequestOptions): IncrementalRequest; } // @public (undocumented) @@ -196,19 +192,35 @@ export namespace Incremental { // (undocumented) prepareRequest: (request: ApolloLink.Request) => ApolloLink.Request; // (undocumented) - startRequest: >(request: { - query: DocumentNode; - }) => IncrementalRequest; + startRequest: >(request: Incremental.StartRequestOptions) => IncrementalRequest; } // (undocumented) export interface IncrementalRequest, TData> { + // (undocumented) + getPendingType?: (id: string) => "defer" | "stream"; // (undocumented) handle: (cacheData: TData | DeepPartial | undefined | null, chunk: Chunk) => FormattedExecutionResult; // (undocumented) hasNext: boolean; + // (undocumented) + pending?: Array; } // (undocumented) export type Path = ReadonlyArray; + // (undocumented) + export interface PendingResult { + // (undocumented) + id: string; + // (undocumented) + label?: string; + // (undocumented) + path: Incremental.Path; + } + // @internal @deprecated (undocumented) + export interface StartRequestOptions { + // (undocumented) + query: DocumentNode; + } // @internal @deprecated (undocumented) export interface StreamFieldInfo { // (undocumented) @@ -220,10 +232,14 @@ export namespace Incremental { // @public (undocumented) class IncrementalRequest implements Incremental.IncrementalRequest, TData> { + // (undocumented) + getPendingType(id: string): "defer" | "stream"; // (undocumented) handle(cacheData: TData | DeepPartial | null | undefined, chunk: GraphQL17Alpha9Handler.Chunk): FormattedExecutionResult; // (undocumented) hasNext: boolean; + // (undocumented) + get pending(): GraphQL17Alpha9Handler.PendingResult[]; } // @public (undocumented) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 033abeb94a6..d70b4c70a58 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -40,11 +40,15 @@ export interface CacheSizes { "inMemoryCache.executeSelectionSet": number; "inMemoryCache.executeSubSelectedArray": number; "inMemoryCache.maybeBroadcastWatch": number; + "inMemoryCache.prunePartialBoundaries": number; + "inMemoryCache.prunePartialStreamArray": number; "PersistedQueryLink.persistedQueryHashes": number; "queryManager.getDocumentInfo": number; "removeTypenameFromVariables.getVariableDefinitions": number; canonicalStringify: number; checkDocument: number; + isDeferredFragment: number; + isStreamField: number; print: number; } diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index 327869a88df..fe0d5edd1e8 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -86,21 +86,6 @@ export type ClassicSignature = SignatureStyle extends "classic" ? unknown : neve // @internal @deprecated export function cloneDeep(value: T): T; -// @public (undocumented) -interface CollectionContext { - // (undocumented) - exclude: SelectionNode; - // Warning: (ae-incompatible-release-tags) The symbol "fragmentMap" is marked as @public, but its signature references "FragmentMap" which is marked as @internal - // - // (undocumented) - fragmentMap: FragmentMap; -} - -// Warning: (ae-forgotten-export) The symbol "CollectionContext" needs to be exported by the entry point index.d.ts -// -// @internal @deprecated -export function collectSiblingFields(selectionSet: SelectionSetNode, context: CollectionContext, visitedFragments?: Map): FieldMap; - // @public export function combineLatestBatched(observables: Array & { dirty?: boolean; @@ -182,6 +167,9 @@ type DeepOmitArray = { // @public (undocumented) type DeepOmitPrimitive = Primitive | Function; +// @internal @deprecated +export type DeferInfoTrie = Trie; + // @public (undocumented) type Directives = { [directiveName: string]: { @@ -237,11 +225,6 @@ export interface ExtensionsWithStreamInfo extends Record { }; } -// @public (undocumented) -export type FieldMap = { - [fieldName: string]: FieldMap | true; -}; - // @public (undocumented) export function filterMap(fn: (value: T, context: undefined) => R | undefined): OperatorFunction; @@ -288,6 +271,8 @@ export const getApolloClientMemoryInternals: (() => { executeSelectionSet: number | undefined; executeSubSelectedArray: number | undefined; maybeBroadcastWatch: number | undefined; + prunePartialBoundaries: number | undefined; + prunePartialStreamArray: number | undefined; } | undefined; fragmentRegistry?: { findFragmentSpreads: number | undefined; @@ -335,6 +320,8 @@ export const getInMemoryCacheMemoryInternals: (() => { executeSelectionSet: number | undefined; executeSubSelectedArray: number | undefined; maybeBroadcastWatch: number | undefined; + prunePartialBoundaries: number | undefined; + prunePartialStreamArray: number | undefined; }; fragmentRegistry: { findFragmentSpreads: number | undefined; @@ -377,6 +364,9 @@ const globalCaches: { // @internal @deprecated (undocumented) export function graphQLResultHasError(result: FormattedExecutionResult): boolean; +// @internal @deprecated +export const handleIncrementalSymbol: unique symbol; + // @internal @deprecated (undocumented) export function hasDirectives(names: string[], root: ASTNode, all?: boolean): boolean; @@ -389,8 +379,8 @@ export type IsAny = 0 extends 1 & T ? true : false; // @internal @deprecated export const isArray: (a: any) => a is any[] | readonly any[]; -// @public (undocumented) -export function isDeferredFragment(fragmentSelection: InlineFragmentNode | FragmentSpreadNode, variables: OperationVariables): boolean; +// @internal @deprecated (undocumented) +export const isDeferredFragment: (fragmentSelection: FragmentSpreadNode | InlineFragmentNode, variables: OperationVariables | undefined) => boolean; // @internal @deprecated (undocumented) export function isDocumentNode(value: unknown): value is DocumentNode; @@ -414,6 +404,9 @@ export function isNonNullObject(obj: unknown): obj is Record; +// @internal @deprecated (undocumented) +export const isStreamField: (field: FieldNode, variables: OperationVariables | undefined) => boolean; + // @public (undocumented) export function isTypenameField(field: FieldNode): boolean; @@ -425,6 +418,9 @@ export type LazyType = T & { // @internal @deprecated (undocumented) export function makeReference(id: string): Reference; +// @internal @deprecated (undocumented) +export function makeStreamInfoTrie(): StreamInfoTrie; + // @internal @deprecated export function makeUniqueId(prefix: string): string; @@ -538,12 +534,25 @@ export function storeKeyNameFromField(field: FieldNode, variables?: Object): str // @public (undocumented) let storeKeyNameStringify: (value: any) => string; +// @public (undocumented) +export class StreamArrayState { + constructor(path: Incremental.Path); + // (undocumented) + depend(): void; + // (undocumented) + get streamPosition(): number; + set streamPosition(value: number); + // (undocumented) + truncate: boolean; +} + // @public export const streamInfoSymbol: unique symbol; // @internal @deprecated (undocumented) export type StreamInfoTrie = Trie<{ current: Incremental.StreamFieldInfo; + state: StreamArrayState; previous?: { incoming: unknown; streamFieldInfo: Incremental.StreamFieldInfo; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 1a07b4260e7..75059c67b10 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -804,6 +804,32 @@ namespace Cache_2 { cache: infer TCache; } ? TCache extends ApolloCache ? TCache : "The cache type declared in TypeOverrides does not extend `ApolloCache` and cannot be used with Apollo Client. See https://www.apollographql.com/docs/react/data/typescript#declaring-the-cache-type." : ApolloCache; // (undocumented) + type InternalDiffResultWithDataState = { + result: DataValue.Complete; + complete: true; + missing?: never; + dataState: "complete"; + fromOptimisticTransaction?: boolean; + } | { + result: DataValue.Streaming; + complete: false; + missing?: never; + dataState: "streaming"; + fromOptimisticTransaction?: boolean; + } | { + result: DataValue.Partial; + complete: false; + missing?: MissingFieldError; + dataState: "partial"; + fromOptimisticTransaction?: boolean; + } | { + result: null; + complete: false; + missing?: MissingFieldError; + dataState: "empty"; + fromOptimisticTransaction?: boolean; + }; + // (undocumented) interface ModifyOptions = Record> { // (undocumented) broadcast?: boolean; @@ -1102,6 +1128,9 @@ export type DefaultOptions = ApolloClient.DefaultOptions; // @public (undocumented) export const defaultPrinter: BaseHttpLink.Printer; +// @internal @deprecated +type DeferInfoTrie = Trie; + // @public (undocumented) interface DeleteModifier { // (undocumented) @@ -1114,9 +1143,22 @@ const _deleteModifier: unique symbol; // @public @deprecated (undocumented) export type DevtoolsOptions = ApolloClient.DevtoolsOptions; +// @internal @deprecated (undocumented) +interface DiffIncrementalInfo { + // Warning: (ae-forgotten-export) The symbol "DeferInfoTrie" needs to be exported by the entry point index.d.ts + // + // (undocumented) + deferInfo?: DeferInfoTrie; + // Warning: (ae-forgotten-export) The symbol "StreamInfoTrie" needs to be exported by the entry point index.d.ts + // + // (undocumented) + streamInfo?: StreamInfoTrie; +} + // @public (undocumented) export type DiffQueryAgainstStoreOptions = ReadQueryOptions & { returnPartialData?: boolean; + [handleIncrementalSymbol]?: true | DiffIncrementalInfo; }; export { disableExperimentalFragmentVariables } @@ -1420,6 +1462,8 @@ const getApolloClientMemoryInternals: (() => { executeSelectionSet: number | undefined; executeSubSelectedArray: number | undefined; maybeBroadcastWatch: number | undefined; + prunePartialBoundaries: number | undefined; + prunePartialStreamArray: number | undefined; } | undefined; fragmentRegistry?: { findFragmentSpreads: number | undefined; @@ -1452,6 +1496,8 @@ const getInMemoryCacheMemoryInternals: (() => { executeSelectionSet: number | undefined; executeSubSelectedArray: number | undefined; maybeBroadcastWatch: number | undefined; + prunePartialBoundaries: number | undefined; + prunePartialStreamArray: number | undefined; }; fragmentRegistry: { findFragmentSpreads: number | undefined; @@ -1468,6 +1514,9 @@ export { gql } // @public @deprecated (undocumented) export type GraphQLRequest = ApolloLink.Request; +// @internal @deprecated +const handleIncrementalSymbol: unique symbol; + // @beta interface HKT { // (undocumented) @@ -1542,19 +1591,35 @@ namespace Incremental { // Warning: (ae-forgotten-export) The symbol "Incremental" needs to be exported by the entry point index.d.ts // // (undocumented) - startRequest: >(request: { - query: DocumentNode; - }) => IncrementalRequest; + startRequest: >(request: Incremental.StartRequestOptions) => IncrementalRequest; } // (undocumented) interface IncrementalRequest, TData> { + // (undocumented) + getPendingType?: (id: string) => "defer" | "stream"; // (undocumented) handle: (cacheData: TData | DeepPartial | undefined | null, chunk: Chunk) => FormattedExecutionResult; // (undocumented) hasNext: boolean; + // (undocumented) + pending?: Array; } // (undocumented) type Path = ReadonlyArray; + // (undocumented) + interface PendingResult { + // (undocumented) + id: string; + // (undocumented) + label?: string; + // (undocumented) + path: Incremental.Path; + } + // @internal @deprecated (undocumented) + interface StartRequestOptions { + // (undocumented) + query: DocumentNode; + } // @internal @deprecated (undocumented) interface StreamFieldInfo { // (undocumented) @@ -1595,6 +1660,8 @@ export namespace InMemoryCache { // @public (undocumented) export class InMemoryCache extends ApolloCache { + // (undocumented) + readonly [handleIncrementalSymbol] = true; constructor(...args: {} extends InMemoryCache.ScalarsOption ? [ config?: InMemoryCacheConfig ] : [config: InMemoryCacheConfig]); @@ -1608,7 +1675,11 @@ export class InMemoryCache extends ApolloCache { // (undocumented) protected config: InMemoryCacheConfig; // (undocumented) - diff(options: Cache_2.DiffOptions): Cache_2.DiffResult; + diff(query: Cache_2.DiffOptions & { + [handleIncrementalSymbol]: true | DiffIncrementalInfo; + }): Cache_2.InternalDiffResultWithDataState; + // (undocumented) + diff(query: Cache_2.DiffOptions): Cache_2.DiffResult; // (undocumented) evict(options: Cache_2.EvictOptions): boolean; // (undocumented) @@ -2962,12 +3033,25 @@ Item // @public export type StoreValue = number | string | string[] | Reference | Reference[] | null | undefined | void | Object; +// @public (undocumented) +class StreamArrayState { + constructor(path: Incremental.Path); + // (undocumented) + depend(): void; + // (undocumented) + get streamPosition(): number; + set streamPosition(value: number); + // (undocumented) + truncate: boolean; +} + // @public const streamInfoSymbol: unique symbol; // @internal @deprecated (undocumented) type StreamInfoTrie = Trie<{ current: Incremental.StreamFieldInfo; + state: StreamArrayState; previous?: { incoming: unknown; streamFieldInfo: Incremental.StreamFieldInfo; @@ -3171,8 +3255,9 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:179:3 - (ae-forgotten-export) The symbol "ScalarNames" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:154:3 - (ae-forgotten-export) The symbol "FragmentRegistryAPI" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:145:3 - (ae-forgotten-export) The symbol "DiffIncrementalInfo" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:149:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:164:3 - (ae-forgotten-export) The symbol "FragmentRegistryAPI" needs to be exported by the entry point index.d.ts // src/core/ApolloClient.ts:201:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts // src/core/ApolloClient.ts:635:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:375:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts @@ -3180,7 +3265,7 @@ interface WriteContext extends ReadMergeModifyContext { // 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 -// src/utilities/internal/types/ExtensionsWithStreamDetails.ts:11:5 - (ae-forgotten-export) The symbol "StreamInfoTrie" needs to be exported by the entry point index.d.ts +// src/utilities/internal/types/StreamInfoTrie.ts:10:3 - (ae-forgotten-export) The symbol "StreamArrayState" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/khaki-singers-rush.md b/.changeset/khaki-singers-rush.md new file mode 100644 index 00000000000..980162df7f7 --- /dev/null +++ b/.changeset/khaki-singers-rush.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where `network-only` incremental queries could cause cache data to leak into the emitted result when a `@defer` or `@stream` boundary already had complete data in the cache. Cache data inside pending `@defer` objects and `@stream` arrays are now pruned so that only completed `@defer` or `@stream` boundaries are returned. + +NOTE: This change only applies to `InMemoryCache` when using `GraphQL17Alpha9Handler`. diff --git a/.changeset/soft-nails-argue.md b/.changeset/soft-nails-argue.md new file mode 100644 index 00000000000..8f3f9c33eb7 --- /dev/null +++ b/.changeset/soft-nails-argue.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where partial cache data could leak into intermediate incremental results. This could cause runtime crashes if you relied on the presence of values to determine whether the `@defer` data had streamed in or not. diff --git a/.size-limits.json b/.size-limits.json index 3df26a76e28..d2976bc77f1 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 49709, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 43841, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 37282, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 30712 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 51043, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 45303, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 38378, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 31815 } diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d8d128640db..dd3709e9d5b 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -439,13 +439,13 @@ Array [ "AutoCleanedStrongCache", "AutoCleanedWeakCache", "DeepMerger", + "StreamArrayState", "argumentsObjectFromField", "bindCacheKey", "canUseDOM", "canonicalStringify", "checkDocument", "cloneDeep", - "collectSiblingFields", "combineLatestBatched", "compact", "createFragmentMap", @@ -472,6 +472,7 @@ Array [ "getStoreKeyName", "getUnwrappedType", "graphQLResultHasError", + "handleIncrementalSymbol", "hasDirectives", "hasForcedResolvers", "isArray", @@ -481,8 +482,10 @@ Array [ "isNonEmptyArray", "isNonNullObject", "isPlainObject", + "isStreamField", "isTypenameField", "makeReference", + "makeStreamInfoTrie", "makeUniqueId", "mapObservableFragmentMemoized", "maybeDeepFreeze", diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index c126c09035a..d2a198a0560 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -376,6 +376,36 @@ export declare namespace Cache { fromOptimisticTransaction?: boolean; }; + export type InternalDiffResultWithDataState = + | { + result: DataValue.Complete; + complete: true; + missing?: never; + dataState: "complete"; + fromOptimisticTransaction?: boolean; + } + | { + result: DataValue.Streaming; + complete: false; + missing?: never; + dataState: "streaming"; + fromOptimisticTransaction?: boolean; + } + | { + result: DataValue.Partial; + complete: false; + missing?: MissingFieldError; + dataState: "partial"; + fromOptimisticTransaction?: boolean; + } + | { + result: null; + complete: false; + missing?: MissingFieldError; + dataState: "empty"; + fromOptimisticTransaction?: boolean; + }; + export type CacheIdentifierOption = | { /** diff --git a/src/cache/index.ts b/src/cache/index.ts index 529ba6833c3..748aaccceca 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -49,6 +49,7 @@ export { createFragmentRegistry } from "./inmemory/fragmentRegistry.js"; export type { ApolloReducerConfig, + DiffIncrementalInfo, DiffQueryAgainstStoreOptions, IdGetter, IdGetterObj, diff --git a/src/cache/inmemory/__tests__/cache.diff/incremental.test.ts b/src/cache/inmemory/__tests__/cache.diff/incremental.test.ts new file mode 100644 index 00000000000..1796ae6e440 --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.diff/incremental.test.ts @@ -0,0 +1,9654 @@ +import { Trie } from "@wry/trie"; + +import { gql } from "@apollo/client"; +import { + createFragmentRegistry, + InMemoryCache, + isReference, + MissingFieldError, +} from "@apollo/client/cache"; +import { markAsStreaming, spyOnConsole } from "@apollo/client/testing/internal"; +import { addTypenameToDocument } from "@apollo/client/utilities"; +import type { DeferInfoTrie } from "@apollo/client/utilities/internal"; +import { + handleIncrementalSymbol, + makeStreamInfoTrie, +} from "@apollo/client/utilities/internal"; + +test('returns dataState "complete" when the cache fully satisfies the query', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when only deferred fields are missing with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when only deferred fields are missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" for a typename-added document when only deferred fields are missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = addTypenameToDocument(gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" when non-deferred fields are missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + recipient: getMissingMessage("recipient", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "empty" when non-deferred fields are missing with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + recipient: getMissingMessage("recipient", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test("strips incomplete fields inside a defer boundary when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('keeps incomplete fields inside a defer boundary when returnPartialData is true and reports dataState "partial"', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Cached Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" and strips a dangling referenced object inside a defer boundary with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + __typename + id + name + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }, + }); + + // Cause a dangling reference on the recipient field + cache.evict({ id: "Person:1" }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for a dangling referenced object inside a defer boundary with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + __typename + id + name + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }, + }); + + cache.evict({ id: "Person:1" }); + + const danglingMessage = "Dangling reference to missing Person:1 object"; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: {}, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + danglingMessage, + { + greeting: { + recipient: danglingMessage, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "partial" with a __typename-only deferred object when selected fields are missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + email + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + phone + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + phone: "555-0100", + }, + }, + }, + }); + + const missingObject = { + __typename: "Person", + phone: "555-0100", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips a __typename-only deferred object when selected fields are missing with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + email + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + phone + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + phone: "555-0100", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" when all selected fields under a deferred object are missing but __typename is present with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + phone + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + phone: "555-0100", + }, + }, + }, + }); + + const missingObject = { + __typename: "Person", + phone: "555-0100", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingObject), + { + greeting: { + recipient: { + name: getMissingMessage("name", missingObject), + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips a deferred object whose own nested object is present but incomplete with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + author { + profile { + bio + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + greeting { + message + author { + id + profile { + __typename + } + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + id: "100", + profile: { __typename: "Profile" }, + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for a deferred object whose own nested object is present but incomplete with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + author { + profile { + bio + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + greeting { + message + author { + id + profile { + __typename + } + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + id: "100", + profile: { __typename: "Profile" }, + }, + }, + }, + }); + } + + const missingObject = { __typename: "Profile" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + profile: { __typename: "Profile" }, + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("bio", missingObject), + { + greeting: { + author: { + profile: { + bio: getMissingMessage("bio", missingObject), + }, + }, + }, + }, + query, + {} + ), + }); +}); + +test("does not treat overlapping non-deferred fields as partial when only deferred siblings are missing", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not surface incomplete cached defer-only fields under an overlapping parent with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + ... on Greeting @defer { + recipient { + name + email + phone + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "cached@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("returns a referentially stable result across reads, rebuilding only the paths changed by a write, when partial @defer boundaries are stripped with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author { + id + name + profile { + bio + } + } + friends { + id + name + } + streamedFriends @stream(initialCount: 1) { + id + name + } + colleagues { + id + name + ... on Person @defer { + email + phone + } + } + friendGroups { + id + name + ... on Person @defer { + email + phone + } + } + manager { + id + name + ... on Person @defer { + salary + } + } + ... on Greeting @defer { + recipient { + name + email + } + } + ... on Greeting @defer { + sender { + name + location { + city + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + id: "100", + name: "Bob", + profile: { __typename: "Profile", bio: "Author bio" }, + }, + // Complete non-deferred list + friends: [ + { __typename: "Person", id: "1", name: "Leia" }, + { __typename: "Person", id: "2", name: "Han" }, + ], + // A partial stream with a visible complete prefix. The prefix must + // be rebuilt when its data changes while the stream stays partial. + streamedFriends: [ + { __typename: "Person", id: "30", name: "Luke" }, + { __typename: "Person", id: "31" }, + ], + // List with a nested @defer with mixed partial + complete items. + // complete items remain unchanged,but partial items lose partial + // defer fields. + colleagues: [ + { + __typename: "Person", + id: "10", + name: "Ada", + email: "ada@example.com", + phone: "555-0010", + }, + { + __typename: "Person", + id: "11", + name: "Grace", + email: "grace@example.com", + }, + // No deferred fields written at all, so this item's defer boundary is + // empty (streaming) with nothing to strip + { + __typename: "Person", + id: "12", + name: "Turing", + }, + ], + // Nested list with mixed partial + complete items. This ensures + // partial deferred fields are stripped without rebuilding untouched + // nested list branches. + friendGroups: [ + [ + { + __typename: "Person", + id: "20", + name: "Mara", + email: "mara@example.com", + }, + { + __typename: "Person", + id: "21", + name: "Wedge", + email: "wedge@example.com", + phone: "555-0021", + }, + ], + [ + { + __typename: "Person", + id: "22", + name: "Rose", + email: "rose@example.com", + }, + ], + ], + // Non-deferred fields present but its nested defer boundary is empty + // (streaming) so it has nothing to strip + manager: { __typename: "Person", id: "200", name: "Katherine" }, + // email is missing, so this deferred fragment is partial and stripped. + recipient: { __typename: "Person", name: "Alice" }, + // Fully present, so this deferred fragment is complete and retained. + sender: { + __typename: "Person", + name: "Sam", + location: { __typename: "Location", city: "Portland" }, + }, + }, + }, + }); + } + + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["greeting", "streamedFriends"]).state.streamPosition = + 1; + + const options = { + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + } as const; + + const diff1 = cache.diff(options); + const diff2 = cache.diff(options); + + expect(diff1).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + id: "100", + name: "Bob", + profile: { __typename: "Profile", bio: "Author bio" }, + }, + friends: [ + { __typename: "Person", id: "1", name: "Leia" }, + { __typename: "Person", id: "2", name: "Han" }, + ], + streamedFriends: [{ __typename: "Person", id: "30", name: "Luke" }], + colleagues: [ + { + __typename: "Person", + id: "10", + name: "Ada", + email: "ada@example.com", + phone: "555-0010", + }, + { __typename: "Person", id: "11", name: "Grace" }, + { __typename: "Person", id: "12", name: "Turing" }, + ], + friendGroups: [ + [ + { __typename: "Person", id: "20", name: "Mara" }, + { + __typename: "Person", + id: "21", + name: "Wedge", + email: "wedge@example.com", + phone: "555-0021", + }, + ], + [{ __typename: "Person", id: "22", name: "Rose" }], + ], + manager: { __typename: "Person", id: "200", name: "Katherine" }, + sender: { + __typename: "Person", + name: "Sam", + location: { __typename: "Location", city: "Portland" }, + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect(diff2.result).toBe(diff1.result); + + // Update a nested object. The nested object and all parent objects + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "100" })!, + fragment: gql` + fragment UpdatedAuthor on Person { + profile { + bio + } + } + `, + data: { + __typename: "Person", + profile: { __typename: "Profile", bio: "Updated bio" }, + }, + }); + + const diff3 = cache.diff(options); + + const greeting1 = (diff1.result as any).greeting; + const greeting3 = (diff3.result as any).greeting; + + expect(diff3.result).not.toBe(diff1.result); + expect(greeting1).not.toBe(greeting3); + expect(greeting3.author).not.toBe(greeting1.author); + expect(greeting3.author.profile).not.toBe(greeting1.author.profile); + expect(greeting3.author.profile.bio).toBe("Updated bio"); + + expect(greeting3.friends).toBe(greeting1.friends); + expect(greeting3.friends[0]).toBe(greeting1.friends[0]); + expect(greeting3.streamedFriends).toBe(greeting1.streamedFriends); + expect(greeting3.colleagues).toBe(greeting1.colleagues); + expect(greeting3.colleagues[0]).toBe(greeting1.colleagues[0]); + expect(greeting3.colleagues[2]).toBe(greeting1.colleagues[2]); + expect(greeting3.friendGroups).toBe(greeting1.friendGroups); + expect(greeting3.friendGroups[0]).toBe(greeting1.friendGroups[0]); + expect(greeting3.friendGroups[1]).toBe(greeting1.friendGroups[1]); + expect(greeting3.manager).toBe(greeting1.manager); + expect(greeting3.sender).toBe(greeting1.sender); + expect(greeting3.sender.location).toBe(greeting1.sender.location); + + // Update a list item to ensure it changes identity along with the parent + // array, but other list items do not. + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "1" })!, + fragment: gql` + fragment UpdatedFriend on Person { + name + } + `, + data: { __typename: "Person", name: "Leia Organa" }, + }); + + const diff4 = cache.diff(options); + const greeting4 = (diff4.result as any).greeting; + + expect(diff4.result).not.toBe(diff3.result); + expect(greeting4.friends).not.toBe(greeting3.friends); + expect(greeting4.friends[0]).not.toBe(greeting3.friends[0]); + + expect(greeting4.friends[0].name).toBe("Leia Organa"); + expect(greeting4.friends[1]).toBe(greeting3.friends[1]); + expect(greeting4.streamedFriends).toBe(greeting3.streamedFriends); + expect(greeting4.author).toBe(greeting3.author); + expect(greeting4.colleagues[0]).toBe(greeting3.colleagues[0]); + expect(greeting4.colleagues[2]).toBe(greeting3.colleagues[2]); + expect(greeting4.friendGroups).toBe(greeting3.friendGroups); + expect(greeting4.manager).toBe(greeting3.manager); + expect(greeting4.sender).toBe(greeting3.sender); + + // Update a field in a partial deferred fragment that is stripped. Object + // identity changes, but the field should remain absent + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "11" })!, + fragment: gql` + fragment UpdatedColleagueEmail on Person { + email + } + `, + data: { __typename: "Person", email: "grace.hopper@example.com" }, + }); + + const diff5 = cache.diff(options); + const greeting5 = (diff5.result as any).greeting; + + expect(greeting5.colleagues[1]).toStrictEqual({ + __typename: "Person", + id: "11", + name: "Grace", + }); + expect(greeting5.colleagues[1]).not.toBe(greeting4.colleagues[1]); + + expect(greeting5.colleagues[0]).toBe(greeting4.colleagues[0]); + expect(greeting5.colleagues[2]).toBe(greeting4.colleagues[2]); + expect(greeting5.friendGroups).toBe(greeting4.friendGroups); + expect(greeting5.manager).toBe(greeting4.manager); + expect(greeting5.friends).toBe(greeting4.friends); + expect(greeting5.streamedFriends).toBe(greeting4.streamedFriends); + expect(greeting5.author).toBe(greeting4.author); + expect(greeting5.sender).toBe(greeting4.sender); + + // Update a field in a partial deferred item nested in a 2d list. The item + // and its ancestor arrays change identity, while untouched sibling branches + // retain their identities. + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "20" })!, + fragment: gql` + fragment UpdatedFriendGroupEmail on Person { + email + } + `, + data: { __typename: "Person", email: "mara.jade@example.com" }, + }); + + const diff6 = cache.diff(options); + const greeting6 = (diff6.result as any).greeting; + + expect(greeting6.friendGroups).not.toBe(greeting5.friendGroups); + expect(greeting6.friendGroups[0]).not.toBe(greeting5.friendGroups[0]); + expect(greeting6.friendGroups[0][0]).not.toBe(greeting5.friendGroups[0][0]); + expect(greeting6.friendGroups[0][0]).toStrictEqual({ + __typename: "Person", + id: "20", + name: "Mara", + }); + expect(greeting6.friendGroups[0][1]).toBe(greeting5.friendGroups[0][1]); + expect(greeting6.friendGroups[1]).toBe(greeting5.friendGroups[1]); + expect(greeting6.friendGroups[1][0]).toBe(greeting5.friendGroups[1][0]); + + // Updating a visible item in a still-partial stream must invalidate the + // pruned array even though its partial boundary has not changed. + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "30" })!, + fragment: gql` + fragment UpdatedStreamedFriend on Person { + name + } + `, + data: { __typename: "Person", name: "Luke Skywalker" }, + }); + + const diff7 = cache.diff(options); + const greeting7 = (diff7.result as any).greeting; + + expect(greeting7.streamedFriends).not.toBe(greeting6.streamedFriends); + expect(greeting7.streamedFriends).toStrictEqual([ + { __typename: "Person", id: "30", name: "Luke Skywalker" }, + ]); + expect(greeting7.friends).toBe(greeting6.friends); + expect(greeting7.friendGroups).toBe(greeting6.friendGroups); +}); + +test("preserves shared object identity for repeated list entities after stripping partial @defer fields with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friends { + id + ... on Person @defer { + name + email + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friends: [ + { __typename: "Person", id: "1", name: "Alice" }, + { __typename: "Person", id: "1", name: "Alice" }, + { + __typename: "Person", + id: "2", + name: "Bob", + email: "bob@example.com", + }, + ], + }, + }); + } + + const partialDiff = cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }); + + const missingPerson1 = { __ref: "Person:1" }; + + expect(partialDiff).toStrictEqualTyped({ + result: { + friends: [ + { __typename: "Person", id: "1", name: "Alice" }, + { __typename: "Person", id: "1", name: "Alice" }, + { + __typename: "Person", + id: "2", + name: "Bob", + email: "bob@example.com", + }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingPerson1), + { + friends: { + 0: { email: getMissingMessage("email", missingPerson1) }, + 1: { email: getMissingMessage("email", missingPerson1) }, + }, + }, + query, + {} + ), + }); + + const partialFriends = (partialDiff.result as any).friends; + + // Precondition: memoized read shares identity for the duplicate entity. + expect(partialFriends[0]).toBe(partialFriends[1]); + expect(partialFriends[0]).not.toBe(partialFriends[2]); + + const strippedDiff = cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }); + + expect(strippedDiff).toStrictEqualTyped({ + result: markAsStreaming({ + friends: [ + { __typename: "Person", id: "1" }, + { __typename: "Person", id: "1" }, + { + __typename: "Person", + id: "2", + name: "Bob", + email: "bob@example.com", + }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + const strippedFriends = (strippedDiff.result as any).friends; + + // Same entity at two indexes must still be the same object after strip. + expect(strippedFriends[0]).toBe(strippedFriends[1]); + expect(strippedFriends[0]).not.toBe(strippedFriends[2]); +}); + +test("returns a referentially stable streaming result when a __typename-only deferred object is stripped, rebuilding only written paths with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author { + id + name + } + ... on Greeting @defer { + recipient { + email + } + } + ... on Greeting @defer { + sender { + id + name + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + greeting { + message + author { + id + name + } + recipient { + __typename + name + } + sender { + id + name + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { __typename: "Person", id: "100", name: "Bob" }, + recipient: { __typename: "Person", name: "Alice" }, + sender: { __typename: "Person", id: "200", name: "Sam" }, + }, + }, + }); + } + + const options = { + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + } as const; + + const diff1 = cache.diff(options); + const diff2 = cache.diff(options); + + expect(diff1).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { __typename: "Person", id: "100", name: "Bob" }, + sender: { __typename: "Person", id: "200", name: "Sam" }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect(diff2.result).toBe(diff1.result); + + const greeting1 = (diff1.result as any).greeting; + + cache.writeFragment({ + id: cache.identify({ __typename: "Person", id: "100" })!, + fragment: gql` + fragment UpdatedAuthor on Person { + name + } + `, + data: { __typename: "Person", id: "100", name: "Robert" }, + }); + + const diff3 = cache.diff(options); + const greeting3 = (diff3.result as any).greeting; + + expect(diff3.result).not.toBe(diff1.result); + expect(greeting3).not.toBe(greeting1); + expect(greeting3.author).not.toBe(greeting1.author); + expect(greeting3.author.name).toBe("Robert"); + expect(greeting3.sender).toBe(greeting1.sender); +}); + +test('returns dataState "partial" under an overlapping parent when a defer-only field is present and another is still missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + ... on Greeting @defer { + recipient { + name + email + phone + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "cached@example.com", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + name: "Alice", + email: "cached@example.com", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "cached@example.com", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", missingObject), + { + greeting: { + recipient: { + phone: getMissingMessage("phone", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("does not surface incomplete cached deep defer-only fields under an overlapped path with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + address { + city + } + } + ... on Greeting @defer { + recipient { + address { + city + postalCode + line2 + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + postalCode: "00000", + }, + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + }, + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when overlapping non-deferred fields are contributed by a fragment spread', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ...GreetingRecipient + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + + fragment GreetingRecipient on Greeting { + recipient { + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when overlapping non-deferred fields are contributed by an inline fragment', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting { + recipient { + name + } + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when non-deferred fields for the same response key are split across sibling selection sets', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + recipient { + id + } + ... on Greeting @defer { + recipient { + name + id + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + id: "1", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + id: "1", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when non-deferred named fragments contribute different subfields under the same response key with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ...RecipientName + ...RecipientId + ... on Greeting @defer { + recipient { + id + name + email + } + } + } + } + + fragment RecipientName on Greeting { + recipient { + name + } + } + + fragment RecipientId on Greeting { + recipient { + id + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + id: "1", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + id: "1", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when overlapping non-deferred list fields are complete and only defer-only item fields are missing', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + person { + id + friends { + id + name + } + ... on Person @defer { + friends { + id + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not surface incomplete cached defer-only list item fields under an overlapping list parent with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + person { + id + friends { + id + name + } + ... on Person @defer { + friends { + id + name + email + phone + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + name: "Leia", + email: "cached-leia@example.com", + }, + { + __typename: "Person", + id: "3", + name: "Han", + }, + ], + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "empty" when an overlapping non-deferred field is missing even if deferred siblings are present with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + email: "alice@example.com", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + email: "alice@example.com", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingObject), + { + greeting: { + recipient: { + name: getMissingMessage("name", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips incomplete fields inside a deferred named fragment with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ...GreetingRecipient @defer + } + } + + fragment GreetingRecipient on Greeting { + recipient { + name + email + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips incomplete fields inside a defer boundary contributed by a registered named fragment with returnPartialData: false", () => { + const cache = new InMemoryCache({ + fragments: createFragmentRegistry(gql` + fragment GreetingRecipient on Greeting { + recipient { + name + email + } + } + `), + }); + const query = gql` + query { + greeting { + message + ...GreetingRecipient @defer + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + __typename + name + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not reuse a cached fragment-resolution error across queries when a registered deferred fragment's spread is later defined", () => { + const cache = new InMemoryCache({ + fragments: createFragmentRegistry(gql` + fragment GreetingWithDeferredRecipient on Greeting { + message + ... @defer { + ...DeferredRecipientFields + } + } + `), + }); + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + __typename + name + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const queryMissingFragment = gql` + query MissingFragment { + greeting { + ...GreetingWithDeferredRecipient + } + } + `; + + const queryProvidingFragment = gql` + query ProvidingFragment { + greeting { + ...GreetingWithDeferredRecipient + } + } + + fragment DeferredRecipientFields on Greeting { + recipient { + name + } + } + `; + + // Reading the registered deferred fragment without a definition for its + // `...DeferredRecipientFields` spread throws. This must not poison the cache + // entry for the shared registry fragment node. + expect(() => { + cache.diff({ + query: queryMissingFragment, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }); + }).toThrow(/No fragment named DeferredRecipientFields/); + + // Reading the same registered deferred fragment from a query that defines + // `DeferredRecipientFields` must succeed rather than re-throwing the error + // cached from the previous read. + expect( + cache.diff({ + query: queryProvidingFragment, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("strips incomplete nested fields inside a nested defer boundary with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + profile { + bio + avatarUrl + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + profile: { + __typename: "Profile", + bio: "Hello", + }, + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("returns incomplete deferred data written only inside the boundary without non-deferred siblings as streaming when fully absent", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for pure @stream when all selected fields on written items are present', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" with empty array with incomplete item with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("returns truncated list before a partial item when streamInfo is provided before truncation is set", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.streamPosition = 1; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not reuse a streamInfo-truncated result when streamInfo is later omitted", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.streamPosition = 1; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not truncate a partial stream array when returnPartialData is true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.streamPosition = 1; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + } + + const missingRef = { __ref: "Friend:3" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 2: { + name: getMissingMessage("name", missingRef), + }, + }, + }, + query, + {} + ), + }); +}); + +test("returns only the complete prefix when streamInfo includes a partial stream item", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.streamPosition = 4; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("returns the full stream array when every item is complete", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.streamPosition = 1; + + cache.writeQuery({ + query, + 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" }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + 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", + complete: true, + missing: undefined, + }); +}); + +test("truncates a complete stream array to the stream position when truncation is enabled", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + streamInfo.lookupArray(["friendList"]).state.truncate = true; + streamInfo.lookupArray(["friendList"]).state.streamPosition = 1; + + cache.writeQuery({ + query, + 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" }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }) + ).toStrictEqualTyped({ + result: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("invalidates a pruned stream array when the stream position changes", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + const streamInfo = makeStreamInfoTrie(); + const streamEntry = streamInfo.lookupArray(["friendList"]); + streamEntry.state.streamPosition = 1; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + const diff = () => + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { streamInfo }, + }); + + expect(diff().result).toStrictEqualTyped({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }); + + streamEntry.state.streamPosition = 2; + + expect(diff().result).toStrictEqualTyped({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }); +}); + +test('returns dataState "complete" with an empty list when every stream list item is incomplete with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('keeps incomplete stream list items when returnPartialData is true and reports dataState "partial"', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }, + }); + } + + const missingRef = { __ref: "Friend:2" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 1: { + name: getMissingMessage("name", missingRef), + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "complete" with empty array with partial array item under a combined @stream and @defer selection with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + ... on Friend @defer { + email + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + { + __typename: "Friend", + id: "2", + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("returns streaming for complete non-deferred stream fields when only deferred fields are missing", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + ... on Friend @defer { + email + } + } + } + `; + + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for sibling @stream and a complete @defer with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" with an empty stream array when an item is incomplete alongside a complete @defer with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("complete is only true when dataState is complete", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "empty" for incomplete fields under @defer(if: false) with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer(if: false) { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Cached Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "empty" for incomplete fields under @defer(if: $shouldDefer) when the variable disables defer with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query ($shouldDefer: Boolean!) { + greeting { + message + ... on Greeting @defer(if: $shouldDefer) { + recipient { + name + email + } + } + } + } + `; + const variables = { shouldDefer: false }; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Cached Alice" }; + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + variables + ), + }); +}); + +test('returns dataState "streaming" for incomplete fields under @defer(if: $shouldDefer) when the variable enables defer with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query ($shouldDefer: Boolean!) { + greeting { + message + ... on Greeting @defer(if: $shouldDefer) { + recipient { + name + email + } + } + } + } + `; + const variables = { shouldDefer: true }; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "empty" when an incomplete list item appears before complete items with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }); + } + + const missingRef = { __ref: "Friend:1" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 0: { name: getMissingMessage("name", missingRef) }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "partial" when an incomplete list item appears before complete items with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }); + } + + const missingRef = { __ref: "Friend:1" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 0: { name: getMissingMessage("name", missingRef) }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" when an earlier list item is still streaming and a later item is complete', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList { + id + name + ... on Friend @defer { + email + } + } + } + `; + + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when an earlier list item is complete and a later item is still streaming', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList { + id + name + ... on Friend @defer { + email + } + } + } + `; + + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" when a complete scalar sibling precedes a partial list with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + meta + friendList { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + meta: "ok", + friendList: [{ __typename: "Friend", id: "1" }], + }, + }); + } + + const missingRef = { __ref: "Friend:1" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + meta: "ok", + friendList: [{ __typename: "Friend", id: "1" }], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 0: { name: getMissingMessage("name", missingRef) }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "empty" when a complete scalar sibling precedes a partial list with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + meta + friendList { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + meta: "ok", + friendList: [{ __typename: "Friend", id: "1" }], + }, + }); + } + + const missingRef = { __ref: "Friend:1" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendList: { + 0: { name: getMissingMessage("name", missingRef) }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "partial" when an empty list is present and a sibling field is missing with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + name + friends { + id + } + } + `; + + cache.writeQuery({ + query: gql` + query { + friends { + id + } + } + `, + data: { + friends: [], + }, + }); + + const missingRoot = { __ref: "ROOT_QUERY" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friends: [], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRoot), + { name: getMissingMessage("name", missingRoot) }, + query, + {} + ), + }); +}); + +test('returns dataState "empty" when an empty list is present and a sibling field is missing with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + name + friends { + id + } + } + `; + + cache.writeQuery({ + query: gql` + query { + friends { + id + } + } + `, + data: { + friends: [], + }, + }); + + const missingRoot = { __ref: "ROOT_QUERY" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRoot), + { name: getMissingMessage("name", missingRoot) }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" when a complete scalar sibling precedes a streaming list', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + meta + friendList { + id + name + ... on Friend @defer { + email + } + } + } + `; + + cache.writeQuery({ + query, + data: { + meta: "ok", + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + meta: "ok", + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when a complete object field precedes a streaming sibling object field', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + a { + x + } + b { + y + ... on B @defer { + z + } + } + } + `; + + cache.writeQuery({ + query, + data: { + a: { __typename: "A", x: 1 }, + b: { __typename: "B", y: 2 }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + a: { __typename: "A", x: 1 }, + b: { __typename: "B", y: 2 }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when a streaming object field precedes a complete sibling object field', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + b { + y + ... on B @defer { + z + } + } + a { + x + } + } + `; + + cache.writeQuery({ + query, + data: { + a: { __typename: "A", x: 1 }, + b: { __typename: "B", y: 2 }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + b: { __typename: "B", y: 2 }, + a: { __typename: "A", x: 1 }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for a fragment-only root selection set when the cache is fully populated', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + ... on Query { + hello + } + } + `; + + cache.writeQuery({ + query, + data: { hello: "world" }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + hello: "world", + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" when an object selection set is only a deferred fragment and that data is fully present', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + ... on Greeting @defer { + message + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "empty" when a root defer boundary has nothing written with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + ... @defer { + greeting { + message + author + } + } + } + `; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" when a root defer boundary is only partially written with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + ... @defer { + greeting { + message + author + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "empty" without missing when a root defer boundary is only partially written with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + ... @defer { + greeting { + message + author + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when an object selection set is only a deferred fragment and that data is absent', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + ... on Greeting @defer { + message + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" when an object selection set is only a deferred fragment, that data is absent, and __typename is selected', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + __typename + ... on Greeting @defer { + message + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for an empty list', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendList { + id + name + } + } + `; + + cache.writeQuery({ + query, + data: { + friendList: [], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" when a nullable object field is explicitly null', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not reuse a returnPartialData: false empty result when the same query is later read with returnPartialData: true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + {} + ), + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test("does not reuse a returnPartialData: true partial result when the same query is later read with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + author + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + {} + ), + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test("does not reuse a stripped deferred result when the same query is later read with returnPartialData: true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" when one of two sibling defer boundaries is still empty', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + ... on Greeting @defer { + author + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" when both sibling defer boundaries are fully present', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + ... on Greeting @defer { + author + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: "Jerel", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: "Jerel", + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not reuse a deferred result when the @defer if variable changes", () => { + const cache = new InMemoryCache(); + const query = gql` + query ($shouldDefer: Boolean!) { + greeting { + recipient @skip(if: $shouldDefer) { + name + } + ... on Greeting @defer(if: $shouldDefer) @include(if: $shouldDefer) { + recipient { + name + email + } + } + ... on Greeting @defer { + author { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables: { shouldDefer: false }, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: { + __typename: "Person", + name: "Jerel", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + variables: { shouldDefer: true }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect( + cache.diff({ + query, + variables: { shouldDefer: false }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not reuse a streamed result when the @stream if variable changes", () => { + const cache = new InMemoryCache(); + const query = gql` + query ($shouldStream: Boolean!) { + friendList @skip(if: $shouldStream) { + id + } + friendList + @stream(initialCount: 1, if: $shouldStream) + @include(if: $shouldStream) { + id + name + } + otherFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables: { shouldStream: true }, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + otherFriendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4" }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + variables: { shouldStream: true }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { friendList: [], otherFriendList: [] }, + dataState: "complete", + complete: true, + missing: undefined, + }); + + expect( + cache.diff({ + query, + variables: { shouldStream: false }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + ], + otherFriendList: [], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not reuse an @include result when its if variable changes", () => { + const cache = new InMemoryCache(); + const query = gql` + query ($includeRecipient: Boolean!) { + greeting { + recipient @include(if: $includeRecipient) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables: { includeRecipient: true }, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + variables: { includeRecipient: true }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect( + cache.diff({ + query, + variables: { includeRecipient: false }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not reuse an @skip result when its if variable changes", () => { + const cache = new InMemoryCache(); + const query = gql` + query ($skipRecipient: Boolean!) { + greeting { + recipient @skip(if: $skipRecipient) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables: { skipRecipient: false }, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + variables: { skipRecipient: false }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect( + cache.diff({ + query, + variables: { skipRecipient: true }, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "empty" when a deferred fragment is excluded via @skip and a non-deferred field is missing', () => { + const cache = new InMemoryCache(); + const query = gql` + query ($skipDefer: Boolean!) { + greeting { + message + author + ... on Greeting @defer @skip(if: $skipDefer) { + recipient { + name + } + } + } + } + `; + const variables = { skipDefer: true }; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + } + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("author", missingObject), + { + greeting: { + author: getMissingMessage("author", missingObject), + }, + }, + query, + variables + ), + }); +}); + +test('returns dataState "streaming" when a deferred fragment is included via @include and only deferred fields are missing', () => { + const cache = new InMemoryCache(); + const query = gql` + query ($includeDefer: Boolean!) { + greeting { + message + ... on Greeting @defer @include(if: $includeDefer) { + recipient { + name + } + } + } + } + `; + const variables = { includeDefer: true }; + + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" when a deferred fragment is excluded via @include(if: false) even if deferred fields are absent', () => { + const cache = new InMemoryCache(); + const query = gql` + query ($includeDefer: Boolean!) { + greeting { + message + ... on Greeting @defer @include(if: $includeDefer) { + recipient { + name + } + } + } + } + `; + const variables = { includeDefer: false }; + + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test.each([ + [ + "@skip(if: true)", + gql` + query { + greeting { + recipient @skip(if: true) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `, + {}, + ], + [ + "@include(if: false)", + gql` + query { + greeting { + recipient @include(if: false) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `, + {}, + ], + [ + "@skip(if: $skipRecipient) with $skipRecipient: true", + gql` + query ($skipRecipient: Boolean!) { + greeting { + recipient @skip(if: $skipRecipient) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `, + { skipRecipient: true }, + ], + [ + "@include(if: $includeRecipient) with $includeRecipient: false", + gql` + query ($includeRecipient: Boolean!) { + greeting { + recipient @include(if: $includeRecipient) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `, + { includeRecipient: false }, + ], +])( + "does not reintroduce partial deferred data through an overlapping %s selection", + (_, query, variables) => { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + variables, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + variables, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +); + +test('returns dataState "streaming" for nested @defer when the outer boundary is complete and the inner boundary is still empty', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" for nested @defer when both defer boundaries are still empty', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for nested @defer when both defer boundaries are fully present', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "partial" for nested @defer when the outer boundary is complete and the inner boundary is incomplete with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", missingObject), + { + greeting: { + recipient: { + phone: getMissingMessage("phone", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips an incomplete nested inner @defer while keeping a complete outer @defer result as streaming when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for nested @defer when the outer boundary has incomplete non-deferred fields with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", missingObject), + { + greeting: { + recipient: { + phone: getMissingMessage("phone", missingObject), + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips an incomplete outer @defer boundary that contains a nested @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "streaming" for nested @defer when only the outer boundary is empty and the inner selection would otherwise be complete', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + } + } + } + } + `; + + // recipient exists in the cache from another write path, but the outer + // deferred field path is not linked from greeting yet. + cache.writeQuery({ + query: gql` + query { + greeting { + message + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips an incomplete outer @defer even when the nested inner @defer is complete with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for nested @defer when the outer boundary is incomplete and the inner boundary is complete with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", missingObject), + { + greeting: { + recipient: { + phone: getMissingMessage("phone", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips an incomplete outer @defer when the nested inner @defer is also incomplete with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + age + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for nested @defer when both outer non-deferred and inner deferred fields are incomplete with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + phone + ... on Person @defer { + email + age + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", missingObject), + { + greeting: { + recipient: { + phone: getMissingMessage("phone", missingObject), + age: getMissingMessage("age", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips only the incomplete nested inner @defer among sibling inners while keeping complete sibling deferred fields when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + ... on Person @defer { + phone + age + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for nested sibling @defer inners when one is incomplete and one is complete with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + ... on Person @defer { + phone + age + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + }); + } + + const missingObject = { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("age", missingObject), + { + greeting: { + recipient: { + age: getMissingMessage("age", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("strips an incomplete nested inner @defer while keeping a streaming sibling inner empty when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + } + ... on Person @defer { + phone + age + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + phone: "555-0100", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips only the incomplete branch in a 3-level nested @defer chain when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + ... on Person @defer { + email + ... on Person @defer { + phone + age + } + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips nested partial data from one sibling outer @defer while keeping another complete outer @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + ... on Greeting @defer { + author { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: { + __typename: "Person", + name: "Bob", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips nested partial data from one sibling outer @defer while ignoring another streaming outer @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + ... on Greeting @defer { + author { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + name: "Bob", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips nested partial data from one sibling outer @defer while stripping another partial outer @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + ... on Greeting @defer { + author { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + name: "Bob", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("keeps overlapping recipient fields selected by a complete sibling @defer when another sibling @defer is partial with returnPartialData: false", () => { + const queries = [ + // complete boundary first + gql` + query { + ... @defer { + recipient { + name + } + } + ... @defer { + recipient { + name + email + } + } + } + `, + // partial boundary first + gql` + query { + ... @defer { + recipient { + name + email + } + } + ... @defer { + recipient { + name + } + } + } + `, + ]; + + for (const query of queries) { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + recipient: { + __typename: "Person", + name: "Alice", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test('returns dataState "streaming" when two sibling @defer fragments overlap and only exclusive fields are missing with returnPartialData: true', () => { + const queries = [ + // complete boundary first + gql` + query { + ... @defer { + recipient { + name + } + } + ... @defer { + recipient { + name + email + } + } + } + `, + // incomplete boundary first + gql` + query { + ... @defer { + recipient { + name + email + } + } + ... @defer { + recipient { + name + } + } + } + `, + ]; + + for (const query of queries) { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + recipient: { + __typename: "Person", + name: "Alice", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test("strips fields from a partial sibling @defer with disjoint selections from a complete sibling @defer regardless of selection order when returnPartialData is false", () => { + const queries = [ + // partial boundary first + gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + ... on Greeting @defer { + author { + name + } + } + } + } + `, + // complete boundary first + gql` + query { + greeting { + message + ... on Greeting @defer { + author { + name + } + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `, + ]; + + for (const query of queries) { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + author: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + author: { + __typename: "Person", + name: "Bob", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test("strips overlapping recipient fields when both sibling @defer boundaries selecting them are partial with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + ... @defer { + recipient { + name + email + } + } + ... @defer { + recipient { + name + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: undefined, + }); +}); + +test("strips nested partial @defer fields contributed under the same response key by sibling fragments with returnPartialData: false", () => { + const queries = [ + // email/bio fragment first + gql` + query { + greeting { + message + ... on Greeting { + recipient { + name + ... on Person @defer { + email + bio + } + } + } + ... on Greeting { + recipient { + name + ... on Person @defer { + phone + age + } + } + } + } + } + `, + // phone/age fragment first + gql` + query { + greeting { + message + ... on Greeting { + recipient { + name + ... on Person @defer { + phone + age + } + } + } + ... on Greeting { + recipient { + name + ... on Person @defer { + email + bio + } + } + } + } + } + `, + ]; + + for (const query of queries) { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test("strips nested partial @defer fields under the same list field contributed by sibling fragments with returnPartialData: false", () => { + const queries = [ + gql` + query { + greeting { + message + ... on Greeting { + friends { + id + name + ... on Friend @defer { + email + bio + } + } + } + ... on Greeting { + friends { + id + name + ... on Friend @defer { + phone + age + } + } + } + } + } + `, + gql` + query { + greeting { + message + ... on Greeting { + friends { + id + name + ... on Friend @defer { + phone + age + } + } + } + ... on Greeting { + friends { + id + name + ... on Friend @defer { + email + bio + } + } + } + } + } + `, + ]; + + for (const query of queries) { + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0001", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + email: "leia@example.com", + phone: "555-0002", + }, + ], + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + }, + ], + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test("does not reintroduce nested stripped @defer fields when a later sibling outer @defer is partial with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipient { + name + ... on Person @defer { + email + phone + } + } + ... on Greeting @defer { + author { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + author: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips a partial @defer nested under an interface fragment that matches a concrete typename via possibleTypes when returnPartialData is false", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Character: ["Human", "Droid"], + }, + }); + const query = gql` + query { + hero { + id + ... on Character { + name + ... on Character @defer { + homePlanet + friendsCount + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + hero: { + __typename: "Human", + id: "1", + name: "Luke", + homePlanet: "Tatooine", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + hero: { + __typename: "Human", + id: "1", + name: "Luke", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips a partial @defer on an interface type condition that matches a concrete typename via possibleTypes when returnPartialData is false", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Character: ["Human", "Droid"], + }, + }); + const query = gql` + query { + hero { + id + ... on Character @defer { + name + homePlanet + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + hero: { + __typename: "Human", + id: "1", + name: "Luke", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + hero: { + __typename: "Human", + id: "1", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips a partial @defer nested under a named fragment on an interface that matches via possibleTypes when returnPartialData is false", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Character: ["Human", "Droid"], + }, + }); + const query = gql` + query { + hero { + id + ...CharacterFields + } + } + + fragment CharacterFields on Character { + name + ... on Character @defer { + homePlanet + friendsCount + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + hero: { + __typename: "Human", + id: "1", + name: "Luke", + homePlanet: "Tatooine", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + hero: { + __typename: "Human", + id: "1", + name: "Luke", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not reintroduce fields from a partial @defer via a non-matching sibling type condition when returnPartialData is false", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Character: ["Human", "Droid"], + }, + }); + const query = gql` + query { + hero { + id + ... on Human @defer { + name + homePlanet + } + ... on Droid { + name + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + hero: { + __typename: "Human", + id: "1", + name: "Luke", + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + hero: { + __typename: "Human", + id: "1", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" when a deferred object field is explicitly null', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" when a deferred scalar field is explicitly null', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: null, + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: null, + }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("preserves an explicitly null list item while stripping partial deferred fields on sibling items when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friends { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friends: [ + null, + { + __typename: "Friend", + id: "1", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + { + __typename: "Friend", + id: "2", + name: "Bob", + email: "bob@example.com", + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friends: [ + null, + { + __typename: "Friend", + id: "1", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + { + __typename: "Friend", + id: "2", + name: "Bob", + }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("preserves a null list field under a parent that also has a partial sibling @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + recipients + ... on Greeting @defer { + author { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipients: null, + author: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipients: null, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("preserves an explicitly null nested object while stripping a partial sibling nested @defer when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + author { + name + ... on Person @defer { + email + phone + } + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + author: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: null, + author: { + __typename: "Person", + name: "Bob", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("honors field aliases when stripping a partial @defer boundary with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + text: message + primary: recipient { + fullName: name + } + ... on Greeting @defer { + contact: recipient { + fullName: name + emailAddress: email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + text: "Hello world", + primary: { + __typename: "Person", + fullName: "Alice", + }, + contact: { + __typename: "Person", + fullName: "Alice", + // emailAddress missing → partial deferred boundary + }, + }, + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + text: "Hello world", + primary: { + __typename: "Person", + fullName: "Alice", + }, + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" for a fully populated 2d scalar array', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + matrix + } + `; + + cache.writeQuery({ + query, + data: { + matrix: [ + [1, 2], + [3, 4], + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + matrix: [ + [1, 2], + [3, 4], + ], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "complete" for a fully populated 2d object array', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + } + } + `; + + cache.writeQuery({ + query, + data: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test('returns dataState "empty" for a 2d object array with an incomplete nested item when returnPartialData is false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + }); + } + + const missingRef = { __ref: "Friend:2" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: null, + dataState: "empty", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendGroups: { + 0: { + 1: { name: getMissingMessage("name", missingRef) }, + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "partial" for a 2d object array with an incomplete nested item when returnPartialData is true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + }); + } + + const missingRef = { __ref: "Friend:2" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendGroups: { + 0: { + 1: { name: getMissingMessage("name", missingRef) }, + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" for a 2d object array when only deferred fields are missing on a nested item', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + ... on Friend @defer { + email + } + } + } + `; + + cache.writeQuery({ + query, + data: { + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips partial deferred fields from nested items in a 2d object array while retaining complete siblings with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendGroups: [ + [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + phone: "555-0102", + }, + ], + [ + { + __typename: "Friend", + id: "3", + name: "Leia", + email: "leia@example.com", + }, + ], + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friendGroups: [ + [ + { __typename: "Friend", id: "1", name: "Luke" }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + phone: "555-0102", + }, + ], + [{ __typename: "Friend", id: "3", name: "Leia" }], + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for a 2d object array when a nested item has incomplete non-deferred fields with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friendGroups { + id + name + ... on Friend @defer { + email + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendGroups: [ + [{ __typename: "Friend", id: "1" }], + [{ __typename: "Friend", id: "2", name: "Leia" }], + ], + }, + }); + } + + const missingRef = { __ref: "Friend:1" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friendGroups: [ + [{ __typename: "Friend", id: "1" }], + [{ __typename: "Friend", id: "2", name: "Leia" }], + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("name", missingRef), + { + friendGroups: { + 0: { + 0: { + name: getMissingMessage("name", missingRef), + email: getMissingMessage("email", missingRef), + }, + }, + 1: { + 0: { + email: getMissingMessage("email", { __ref: "Friend:2" }), + }, + }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" when a partial @defer inside every list item is stripped with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friends { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + email: "leia@example.com", + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friends: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Leia" }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" when a partial @defer inside a list item is present with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friends { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + email: "leia@example.com", + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + email: "leia@example.com", + }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("phone", { __ref: "Friend:1" }), + { + friends: { + 0: { phone: getMissingMessage("phone", { __ref: "Friend:1" }) }, + 1: { phone: getMissingMessage("phone", { __ref: "Friend:2" }) }, + }, + }, + query, + {} + ), + }); +}); + +test('returns dataState "streaming" keeping complete per-item @defer data while stripping a partial sibling item with returnPartialData: false', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + friends { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0001", + }, + { + __typename: "Friend", + id: "2", + name: "Leia", + email: "leia@example.com", + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + friends: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0001", + }, + { __typename: "Friend", id: "2", name: "Leia" }, + ], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "complete" with an empty object when all fields are skipped', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + firstName @include(if: false) + lastName @skip(if: true) + } + `; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: {}, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("strips a __typename-only deferred object nested inside a list item with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + colleagues { + id + name + ... on Person @defer { + profile { + bio + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + colleagues { + __typename + id + name + profile { + __typename + } + } + } + `, + data: { + colleagues: [ + { + __typename: "Person", + id: "1", + name: "Grace", + profile: { __typename: "Profile" }, + }, + ], + }, + }); + } + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + colleagues: [{ __typename: "Person", id: "1", name: "Grace" }], + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test('returns dataState "partial" for a __typename-only deferred object nested inside a list item with returnPartialData: true', () => { + const cache = new InMemoryCache(); + const query = gql` + query { + colleagues { + id + name + ... on Person @defer { + profile { + bio + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query: gql` + query { + colleagues { + __typename + id + name + profile { + __typename + } + } + } + `, + data: { + colleagues: [ + { + __typename: "Person", + id: "1", + name: "Grace", + profile: { __typename: "Profile" }, + }, + ], + }, + }); + } + + const missingObject = { __typename: "Profile" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + colleagues: [ + { + __typename: "Person", + id: "1", + name: "Grace", + profile: { __typename: "Profile" }, + }, + ], + }, + dataState: "partial", + complete: false, + missing: new MissingFieldError( + getMissingMessage("bio", missingObject), + { + colleagues: { + 0: { + profile: { + bio: getMissingMessage("bio", missingObject), + }, + }, + }, + }, + query, + {} + ), + }); +}); + +// --- Backwards compatibility tests --- +// We want to make the cache fully incremental aware in v5 without the symbol +// workaround to maintain the backwards compatibility that we have in 4.x. Once +// v5 is in place, we can delete the following tests since the standard behavior +// should be tested by everything above. + +test("without handleIncrementalSymbol, missing deferred fields yield null when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + }) + ).toStrictEqualTyped({ + result: null, + complete: false, + missing: new MissingFieldError( + getMissingMessage("recipient", missingObject), + { + greeting: { + recipient: getMissingMessage("recipient", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test("without handleIncrementalSymbol, missing deferred fields return partial data when returnPartialData is true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + }); + + const missingObject = { __typename: "Greeting", message: "Hello world" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }, + complete: false, + missing: new MissingFieldError( + getMissingMessage("recipient", missingObject), + { + greeting: { + recipient: getMissingMessage("recipient", missingObject), + }, + }, + query, + {} + ), + }); +}); + +test("without handleIncrementalSymbol, incomplete fields inside a defer boundary yield null when returnPartialData is false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Cached Alice", + }, + }, + }, + }); + } + + const missingObject = { __typename: "Person", name: "Cached Alice" }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + }) + ).toStrictEqualTyped({ + result: null, + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("without handleIncrementalSymbol, retains a __typename-only deferred object when selected fields are missing with returnPartialData: true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + email + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + phone + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + phone: "555-0100", + }, + }, + }, + }); + + const missingObject = { + __typename: "Person", + phone: "555-0100", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + }, + }, + }, + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("without handleIncrementalSymbol, a __typename-only deferred object yields null when selected fields are missing with returnPartialData: false", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + email + } + } + } + } + `; + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + phone + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + phone: "555-0100", + }, + }, + }, + }); + + const missingObject = { + __typename: "Person", + phone: "555-0100", + }; + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + }) + ).toStrictEqualTyped({ + result: null, + complete: false, + missing: new MissingFieldError( + getMissingMessage("email", missingObject), + { + greeting: { + recipient: { + email: getMissingMessage("email", missingObject), + }, + }, + }, + query, + {} + ), + }); +}); + +test("without handleIncrementalSymbol, a fully satisfied deferred query is complete and omits dataState", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + complete: true, + missing: undefined, + }); +}); + +test("prunes a complete cached @defer boundary when deferInfo marks it as pending", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("keeps a cached @defer boundary that deferInfo does not mark as pending while pruning a sibling boundary that it does", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + hero { + id + ... @defer { + name + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not apply deferInfo pruning when returnPartialData is true", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: true, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("prunes cached @defer boundaries for list items marked pending in deferInfo", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + person { + id + friends { + id + ... @defer { + name + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("person", "friends", 0); + deferInfo.lookup("person", "friends", 1); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("keeps a delivered list item's @defer boundary while pruning a still-pending sibling", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + person { + id + friends { + id + ... @defer { + name + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("person", "friends", 1); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3" }, + ], + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("does not reuse a deferInfo-pruned result when deferInfo is later omitted", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: true, + }) + ).toStrictEqualTyped({ + result: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + complete: true, + missing: undefined, + }); +}); + +test("does not reuse a deferInfo-pruned result when a sibling boundary is delivered on a later read", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + hero { + id + ... @defer { + name + } + } + } + `; + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }, + }); + + { + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + deferInfo.lookup("hero"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + hero: { __typename: "Hero", id: "1" }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } + + { + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("hero"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + hero: { __typename: "Hero", id: "1" }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); + } +}); + +test("strips a partial pending @defer boundary while keeping a complete delivered sibling boundary", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + hero { + id + ... @defer { + name + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }, + }); + } + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +test("strips both a partial pending @defer boundary and a complete pending sibling boundary", () => { + const cache = new InMemoryCache(); + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + email + } + } + } + hero { + id + ... @defer { + name + } + } + } + `; + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + hero: { __typename: "Hero", id: "1", name: "Luke" }, + }, + }); + } + + const deferInfo: DeferInfoTrie = new Trie(); + deferInfo.lookup("greeting"); + deferInfo.lookup("hero"); + + expect( + cache.diff({ + query, + optimistic: true, + returnPartialData: false, + [handleIncrementalSymbol]: { deferInfo }, + }) + ).toStrictEqualTyped({ + result: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + hero: { __typename: "Hero", id: "1" }, + }), + dataState: "streaming", + complete: false, + missing: undefined, + }); +}); + +function getMissingMessage(fieldName: string, obj: Record) { + return `Can't find field '${fieldName}' on ${ + isReference(obj) ? + obj.__ref + " object" + : "object " + JSON.stringify(obj, null, 2) + }`; +} diff --git a/src/cache/inmemory/__tests__/fragmentRegistry.ts b/src/cache/inmemory/__tests__/fragmentRegistry.ts index 64f60a76cac..0310b5b9534 100644 --- a/src/cache/inmemory/__tests__/fragmentRegistry.ts +++ b/src/cache/inmemory/__tests__/fragmentRegistry.ts @@ -220,6 +220,140 @@ describe("FragmentRegistry", () => { }); }); + it("does not reuse a cached fragment-resolution error across queries with differing available fragments", () => { + const cache = new InMemoryCache({ + fragments: createFragmentRegistry(gql` + fragment RegisteredWithHole on Person { + __typename + id + ...ProvidedByQuery + } + `), + }); + + cache.writeQuery({ + query: gql` + query { + me { + __typename + id + name + } + } + `, + data: { + me: { + __typename: "Person", + id: 12345, + name: "Ada", + }, + }, + }); + + const queryMissingFragment = gql` + query MissingFragment { + me { + ...RegisteredWithHole + } + } + `; + + const queryProvidingFragment = gql` + query ProvidingFragment { + me { + ...RegisteredWithHole + } + } + + fragment ProvidedByQuery on Person { + name + } + `; + + // Reading the registered fragment without a definition for its + // `...ProvidedByQuery` spread throws. Critically, this must not poison the + // cache entry for the shared registry fragment node. + expect(() => { + cache.readQuery({ query: queryMissingFragment }); + }).toThrow(/No fragment named ProvidedByQuery/); + + // Reading the same registry fragment from a query that *does* define + // `ProvidedByQuery` must succeed rather than re-throwing the error cached + // from the previous read. + expect(cache.readQuery({ query: queryProvidingFragment })).toEqual({ + me: { + __typename: "Person", + id: 12345, + name: "Ada", + }, + }); + }); + + it("does not reuse a cached result across queries that bind a registry fragment's unbound spread to different fields", () => { + const cache = new InMemoryCache({ + fragments: createFragmentRegistry(gql` + fragment PersonFields on Person { + __typename + id + ...Extra + } + `), + }); + + cache.writeQuery({ + query: gql` + query { + me { + __typename + id + name + email + } + } + `, + data: { + me: { + __typename: "Person", + id: 12345, + name: "Ada", + email: "ada@example.com", + }, + }, + }); + + const queryName = gql` + query WithName { + me { + ...PersonFields + } + } + + fragment Extra on Person { + name + } + `; + + const queryEmail = gql` + query WithEmail { + me { + ...PersonFields + } + } + + fragment Extra on Person { + email + } + `; + + expect(cache.readQuery({ query: queryName })).toEqual({ + me: { __typename: "Person", id: 12345, name: "Ada" }, + }); + + expect(cache.readQuery({ query: queryEmail })).toEqual({ + me: { __typename: "Person", id: 12345, email: "ada@example.com" }, + }); + }); + it("can register fragments with unbound ...spreads", () => { const cache = new InMemoryCache({ fragments: createFragmentRegistry(gql` diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 0c9425fb454..301df76bd4b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -30,6 +30,7 @@ import { getInMemoryCacheMemoryInternals, getOperationDefinition, getUnwrappedType, + handleIncrementalSymbol, isPlainObject, } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; @@ -45,6 +46,7 @@ import { Policies } from "./policies.js"; import { forgetCache, makeVar, recallCache } from "./reactiveVars.js"; import { StoreReader } from "./readFromStore.js"; import type { + DiffIncrementalInfo, InMemoryCacheConfig, KnownScalars, NormalizedCacheObject, @@ -107,6 +109,11 @@ export class InMemoryCache extends ApolloCache { // in development and expected to remain logically immutable in production. public readonly assumeImmutableResults = true; + // InMemoryCache can handle incremental results in cache.diff. 3rd party + // caches that want to handle incremental results should talk to the Apollo + // Client team on how to handle this in Apollo Client 4.x. + public readonly [handleIncrementalSymbol] = true; + // Dynamically imported code can augment existing typePolicies or // possibleTypes by calling cache.policies.addTypePolicies or // cache.policies.addPossibletypes. @@ -446,6 +453,20 @@ export class InMemoryCache extends ApolloCache { } } + public diff< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + query: Cache.DiffOptions & { + [handleIncrementalSymbol]: true | DiffIncrementalInfo; + } + ): Cache.InternalDiffResultWithDataState; + + public diff< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >(query: Cache.DiffOptions): Cache.DiffResult; + public diff< TData = unknown, TVariables extends OperationVariables = OperationVariables, diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index ab9a4126338..10c99e71e2e 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -1,4 +1,11 @@ -import type { DocumentNode, FieldNode, SelectionSetNode } from "graphql"; +import { equal } from "@wry/equality"; +import { Trie } from "@wry/trie"; +import type { + DocumentNode, + FieldNode, + SelectionNode, + SelectionSetNode, +} from "graphql"; import { Kind } from "graphql"; import type { OptimisticWrapperFunction } from "optimism"; import { wrap } from "optimism"; @@ -12,8 +19,10 @@ import { } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import type { + DeferInfoTrie, FragmentMap, FragmentMapFunction, + StreamInfoTrie, } from "@apollo/client/utilities/internal"; import { DeepMerger, @@ -21,9 +30,12 @@ import { getFragmentFromSelection, getMainDefinition, getQueryDefinition, + handleIncrementalSymbol, isArray, + isDeferredFragment, isField, isNonNullObject, + isStreamField, makeReference, maybeDeepFreeze, mergeDeepArray, @@ -51,21 +63,41 @@ import { import type { InMemoryCache } from "./inMemoryCache.js"; import type { Policies } from "./policies.js"; import type { + DiffIncrementalInfo, DiffQueryAgainstStoreOptions, InMemoryCacheConfig, NormalizedCache, ReadMergeModifyContext, } from "./types.js"; +type DataState = + // no data + | "empty" + // at least 1 non-deferred field is partial + | "partial" + // at least 1 defer boundary is partial. All non-incremental fields complete + | "deferPartial" + // at least 1 stream boundary is partial. All non-incremental fields complete + | "streamPartial" + // All defer boundaries are complete or empty (but not partial) or all stream + // boundaries are complete (but not partial) + | "streaming" + // All fields have data + | "complete"; + interface ReadContext extends ReadMergeModifyContext { query: DocumentNode; policies: Policies; fragmentMap: FragmentMap; lookupFragment: FragmentMapFunction; + streamInfo?: StreamInfoTrie; + deferInfo?: DeferInfoTrie; } type ExecResult = { result: R; + dataState: DataState; + partialBoundaries: PartialBoundaries; missing?: MissingTree; }; @@ -83,6 +115,22 @@ type ExecSubSelectedArrayOptions = { context: ReadContext; }; +type PruneSelectionSetOptions = { + selectionSet: SelectionSetNode; + context: ReadContext; + path: Array; + data: any; + boundaries: PartialBoundaries | undefined; +}; + +type PruneArrayOptions = { + field: FieldNode; + context: ReadContext; + path: Array; + array: any[]; + boundaries: PartialBoundaries | undefined; +}; + interface StoreReaderConfig { cache: InMemoryCache; fragments?: InMemoryCacheConfig["fragments"]; @@ -116,6 +164,15 @@ export class StoreReader { [ExecSubSelectedArrayOptions] >; + private prunePartialStreamArray: OptimisticWrapperFunction< + [PruneArrayOptions], + any[] + >; + private prunePartialBoundaries: OptimisticWrapperFunction< + [PruneSelectionSetOptions], + Record + >; + private config: { cache: InMemoryCache; fragments?: InMemoryCacheConfig["fragments"]; @@ -123,6 +180,8 @@ export class StoreReader { private knownResults = new WeakMap, SelectionSetNode>(); + private keyMaker = new Trie(); + constructor(config: StoreReaderConfig) { this.config = config; @@ -189,19 +248,78 @@ export class StoreReader { }, } ); + + this.prunePartialBoundaries = wrap( + (options) => this.prunePartialBoundariesImpl(options), + { + max: + cacheSizes["inMemoryCache.prunePartialBoundaries"] || + defaultCacheSizes["inMemoryCache.prunePartialBoundaries"], + makeCacheKey: ({ boundaries, context, selectionSet }) => { + if (supportsResultCaching(context.store)) { + return this.keyMaker.lookup( + selectionSet, + boundaries, + context.streamInfo, + context.deferInfo + ); + } + }, + } + ); + + this.prunePartialStreamArray = wrap( + (options) => { + const { field, context, path } = options; + + if (isStreamField(field, context.variables)) { + context.streamInfo?.lookupArray(path).state.depend(); + } + + return this.prunePartialStreamArrayImpl(options); + }, + { + max: + cacheSizes["inMemoryCache.prunePartialStreamArray"] || + defaultCacheSizes["inMemoryCache.prunePartialStreamArray"], + makeCacheKey: ({ field, context, boundaries }) => { + if (supportsResultCaching(context.store)) { + return this.keyMaker.lookup( + field, + boundaries, + context.streamInfo, + context.deferInfo + ); + } + }, + } + ); } /** * Given a store and a query, return as much of the result as possible and * identify if any data was missing from the store. */ + public diffQueryAgainstStore( + options: DiffQueryAgainstStoreOptions & { + [handleIncrementalSymbol]: true | DiffIncrementalInfo; + } + ): Cache.InternalDiffResultWithDataState; + + public diffQueryAgainstStore( + options: DiffQueryAgainstStoreOptions + ): Cache.DiffResult; + public diffQueryAgainstStore({ store, query, rootId = "ROOT_QUERY", variables, returnPartialData = true, - }: DiffQueryAgainstStoreOptions): Cache.DiffResult { + [handleIncrementalSymbol]: handleIncremental, + }: DiffQueryAgainstStoreOptions): Cache.DiffResult & { + dataState?: "empty" | "partial" | "streaming" | "complete"; + } { const policies = this.config.cache.policies; variables = { @@ -210,39 +328,122 @@ export class StoreReader { }; const rootRef = makeReference(rootId); - const execResult = this.executeSelectionSet({ + const context: ReadContext = { + store, + query, + policies, + variables, + varString: canonicalStringify(variables), + ...extractFragmentContext(query, this.config.fragments), + ...(typeof handleIncremental === "object" ? handleIncremental : ( + undefined + )), + }; + let execResult = this.executeSelectionSet({ selectionSet: getMainDefinition(query).selectionSet, objectOrReference: rootRef, enclosingRef: rootRef, - context: { - store, - query, - policies, - variables, - varString: canonicalStringify(variables), - ...extractFragmentContext(query, this.config.fragments), - }, + context, }); - const { result, missing: rawMissing } = execResult; - const complete = !rawMissing; + // Since executeSelectionSet doesn't know about returnPartialData, we need + // to perform a 2nd pass over the result to prune any fields inside + // partial defer boundaries. The "deferPartial" data state tells us that the + // only part of the result that contributed to its partiality is data inside + // a defer boundary. + if ( + handleIncremental && + (execResult.dataState === "deferPartial" || + execResult.dataState === "streamPartial" || + // If the last cache write repaired a partial @stream array to a + // complete array, the stream array might contain stale entries after + // the last written value. We only want to deliver the results up to + // the index the network wrote so we need to prune it too. + context.streamInfo || + // The network hasn't delivered these @defer boundaries yet, so prune + // the (possibly complete) cached data sitting at them. + context.deferInfo) + ) { + const pruned = this.prunePartialBoundaries({ + selectionSet: getMainDefinition(query).selectionSet, + data: execResult.result, + boundaries: execResult.partialBoundaries, + context, + path: [], + }); + const changed = execResult.result !== pruned; + // It's possible that pruning didn't actually change the result which can + // happen if a defer boundary is misclassified as "deferPartial" instead + // of "streaming" (sibling defer boundaries with overlapping selection + // sets, one of which is complete). In this case, pruning corrects the + // dataState to streaming instead of leaving it as partial. If we tolerate + // partial results and pruning changed the result by dropping fields, then + // we want to keep the original execResult which contains the partial + // data. + if (!changed || !returnPartialData) { + const { dataState } = execResult; + + // Omit `missing` property since pruning puts it in a state that doesn't + // report missing fields. + execResult = { + result: pruned, + partialBoundaries: execResult.partialBoundaries, + dataState: + dataState === "deferPartial" ? "streaming" + : dataState === "streamPartial" ? "complete" + // A cached @defer boundary the network hasn't delivered was + // pruned from an otherwise complete result, so we're still + // streaming. + : context.deferInfo && changed ? "streaming" + : dataState, + }; + } + } + + let { result, dataState, missing } = execResult; + // Evaluate this condition before we start mucking with dataState for the + // publicly returned value + const includeMissing = + !!missing && + // We don't need to report missing fields inside defer boundaries since + // the "streaming" dataState tells us that the only missing fields in + // the object is inside a defer boundary. + (dataState !== "streaming" || !handleIncremental); + + // If we get all root @defer boundaries with an empty result, report it as + // empty instead of streaming. + if (dataState === "streaming" && Object.keys(result).length === 0) { + dataState = "empty"; + } let missingError: MissingFieldError | undefined; - return { - result: - complete ? result - : returnPartialData ? - Object.keys(result).length === 0 ? - null - : result - : null, + if ( + dataState === "deferPartial" || + dataState === "streamPartial" || + (dataState === "streaming" && !handleIncremental) + ) { + dataState = "partial"; + } + + if (dataState === "partial" && !returnPartialData) { + dataState = "empty"; + } + + const complete = dataState === "complete"; + const keepResult = + complete || + dataState === "streaming" || + (returnPartialData && Object.keys(result).length); + + const diffResult = { + result: keepResult ? result : null, complete, get missing() { - if (missingError === void 0 && rawMissing) { - missingError = new MissingFieldError( - firstMissing(rawMissing)!, - rawMissing, + if (includeMissing) { + missingError ||= new MissingFieldError( + firstMissing(missing)!, + missing, query, variables ); @@ -250,6 +451,13 @@ export class StoreReader { return missingError; }, } as Cache.DiffResult; + + if (handleIncremental) { + (diffResult as Cache.InternalDiffResultWithDataState).dataState = + dataState; + } + + return diffResult; } public isFresh( @@ -288,7 +496,9 @@ export class StoreReader { ) { return { result: {}, + dataState: "empty", missing: `Dangling reference to missing ${objectOrReference.__ref} object`, + partialBoundaries: new PartialBoundaries(), }; } @@ -299,8 +509,10 @@ export class StoreReader { ); const objectsToMerge: Record[] = []; + let dataState: DataState | undefined; let missing: MissingTree | undefined; const missingMerger = new DeepMerger(); + const partialBoundaries = new PartialBoundaries(); if (typeof typename === "string" && !policies.rootIdsByTypename[typename]) { // Ensure we always include a default value for the __typename @@ -309,13 +521,13 @@ export class StoreReader { objectsToMerge.push({ __typename: typename }); } - function handleMissing(result: ExecResult, resultName: string): T { + function handleMissing(result: ExecResult, resultName: string) { if (result.missing) { missing = missingMerger.merge(missing, { [resultName]: result.missing, }); } - return result.result; + return result; } const workSet = new Set(selectionSet.selections); @@ -352,10 +564,12 @@ export class StoreReader { : `object ${JSON.stringify(objectOrReference || {}, null, 2)}` }`, }); + + dataState = mergeDataState(dataState, "empty"); } } else if (isArray(fieldValue)) { if (fieldValue.length > 0) { - fieldValue = handleMissing( + const execResult = handleMissing( this.executeSubSelectedArray({ field: selection, array: fieldValue, @@ -364,9 +578,19 @@ export class StoreReader { }), resultName ); + + fieldValue = execResult.result; + dataState = mergeDataState(dataState, execResult.dataState); + partialBoundaries.set(resultName, execResult.partialBoundaries); + } else { + dataState = mergeDataState(dataState, "complete"); } } else if (!selection.selectionSet) { - // do nothing + // Auto-inserted __typename should not affect dataState (see empty + // @defer boundaries, which must stay "empty" → parent "streaming"). + if (!addTypenameToDocument.added(selection)) { + dataState = mergeDataState(dataState, "complete"); + } } else if (fieldValue != null) { if (__DEV__) { const fieldName = selection.name.value; @@ -386,7 +610,7 @@ export class StoreReader { // In this case, because we know the field has a selection set, // it must be trying to query a GraphQLObjectType, which is why // fieldValue must be != null. - fieldValue = handleMissing( + const execResult = handleMissing( this.executeSelectionSet({ selectionSet: selection.selectionSet, objectOrReference: fieldValue as StoreObject | Reference, @@ -395,6 +619,27 @@ export class StoreReader { }), resultName ); + + fieldValue = execResult.result; + partialBoundaries.set(resultName, execResult.partialBoundaries); + + // If the object's fields resolved to an "empty" dataState (e.g. no + // field resolved with a non-undefined value), but the fieldValue + // object itself is present, this object should be considered + // partial instead of empty. This also ensures defer boundaries that + // select this object remain as partial defer boundaries rather than + // mistakenly get reported as streaming. This is especially necessary + // when combined with GraphQL Codegen which generates its type and + // relies on the outer object to be absent when its fields haven't + // streamed in. Reporting the defer boundary as "streaming" instead of + // "partial" would otherwise have the potential to cause runtime + // crashes since the runtime values and types would not line up + // properly (types expect object to be undefined, but its instead + // present without its fields) + dataState = mergeDataState( + dataState, + execResult.dataState === "empty" ? "partial" : execResult.dataState + ); } if (fieldValue !== void 0) { @@ -411,13 +656,70 @@ export class StoreReader { } if (fragment && policies.fragmentMatches(fragment, typename)) { - fragment.selectionSet.selections.forEach(workSet.add, workSet); + const isDeferBoundary = isDeferredFragment( + selection, + context.variables + ); + // Prior to 4.3, this branch just flattened the fragment's + // selectionSet into the existing workSet so that it continued + // iterating as if the fragment didn't exist. The cache is + // incremental aware as of 4.3 and as such, we need to resolve the + // per-fragment selection set so that we can properly strip partial + // defer fragment data when returnPartialData is false. We need to + // call execSelectionSetImpl directly (non-cached version) so that we + // scope the dataState correctly for its fields. Using the cached + // executeSelectionSet can result in cache poisoning when combined + // with the fragment registry where it might cache either a) an error + // thrown when a registered fragment references a named fragment that + // the query is expected to supply and doesn't or b) resolve to the + // wrong data result when combined with queries that provide different + // implementations of the same fragment (see inmemory/fragmentRegistry and + // cache.diff/incremental tests which provide guards against this + // behavior). + const execResult = this.execSelectionSetImpl({ + selectionSet: fragment.selectionSet, + objectOrReference, + enclosingRef, + context, + }); + const { result, dataState: nextDataState } = execResult; + + partialBoundaries.merge(execResult.partialBoundaries); + + if (result !== void 0) { + objectsToMerge.push(result); + } + + if (execResult.missing) { + missing = missingMerger.merge(missing, execResult.missing); + } + + if (isDeferBoundary && nextDataState === "partial") { + partialBoundaries.add(selection); + } + + dataState = mergeDataState( + dataState, + isDeferBoundary ? + nextDataState === "empty" ? "streaming" + : nextDataState === "partial" ? "deferPartial" + : nextDataState + : nextDataState + ); } } }); + dataState ||= "complete"; + const result = mergeDeepArray(objectsToMerge); - const finalResult: ExecResult = { result, missing }; + + const finalResult: ExecResult = { + result, + missing, + dataState, + partialBoundaries, + }; const frozen = maybeDeepFreeze(finalResult); // Store this result with its selection set so that we can quickly @@ -436,8 +738,11 @@ export class StoreReader { enclosingRef, context, }: ExecSubSelectedArrayOptions): ExecResult { + let dataState: DataState = "complete"; let missing: MissingTree | undefined; let missingMerger = new DeepMerger(); + const partialBoundaries = new PartialBoundaries(); + const isStreamed = isStreamField(field, context.variables); function handleMissing(childResult: ExecResult, i: number): T { if (childResult.missing) { @@ -458,30 +763,46 @@ export class StoreReader { return null; } + let execResult: ExecResult | undefined; + // This is a nested array, recurse if (isArray(item)) { - return handleMissing( - this.executeSubSelectedArray({ - field, - array: item, - enclosingRef, - context, - }), - i - ); + execResult = this.executeSubSelectedArray({ + field, + array: item, + enclosingRef, + context, + }); + } else if (field.selectionSet) { + execResult = this.executeSelectionSet({ + selectionSet: field.selectionSet, + objectOrReference: item, + enclosingRef: isReference(item) ? item : enclosingRef, + context, + }); } - // This is an object, run the selection set on it - if (field.selectionSet) { - return handleMissing( - this.executeSelectionSet({ - selectionSet: field.selectionSet, - objectOrReference: item, - enclosingRef: isReference(item) ? item : enclosingRef, - context, - }), - i + if (execResult) { + const { dataState: nextDataState } = execResult; + + partialBoundaries.set( + i, + nextDataState === "partial" ? + // avoid mutating the execResult partialBoundaries object + execResult.partialBoundaries.clone().add(field) + : execResult.partialBoundaries + ); + + dataState = mergeDataState( + dataState, + isStreamed ? + nextDataState === "partial" ? + "streamPartial" + : nextDataState + : nextDataState ); + + return handleMissing(execResult, i); } if (__DEV__) { @@ -493,9 +814,183 @@ export class StoreReader { return { result: array, + dataState, missing, + partialBoundaries, }; } + + private prunePartialBoundariesImpl({ + boundaries, + context, + data, + path, + selectionSet, + }: PruneSelectionSetOptions): any { + const { variables, lookupFragment, policies } = context; + + if (data == null || !boundaries) return data; + + const merger = new DeepMerger(); + + let changed = false; + const result: Record = {}; + + // __typename might not be part of the selection set, so preserve it when + // available, otherwise it gets removed since it's never visited when + // iterating the selection set. + if (Object.hasOwn(data, "__typename")) { + result.__typename = data.__typename; + } + + const workSet = new Set(selectionSet.selections); + workSet.forEach((selection) => { + if (!shouldInclude(selection, variables)) return; + + if (isField(selection)) { + const resultName = resultKeyNameFromField(selection); + + if (!Object.hasOwn(data, resultName)) { + return; + } + + const fieldValue = data[resultName]; + + if (Array.isArray(fieldValue)) { + const pruned = this.prunePartialStreamArray({ + field: selection, + array: fieldValue, + boundaries: boundaries.getChild(resultName), + context, + path: path.concat(resultName), + }); + + changed ||= pruned !== fieldValue; + result[resultName] = pruned; + } else if (!selection.selectionSet) { + result[resultName] = fieldValue; + } else { + const pruned = this.prunePartialBoundaries({ + data: fieldValue, + selectionSet: selection.selectionSet, + boundaries: boundaries.getChild(resultName), + context, + path: path.concat(resultName), + }); + + changed ||= pruned !== fieldValue; + + // A response key can be selected by more than one selection (e.g. a + // field and an overlapping fragment), so merge their kept fields. + result[resultName] = + Object.hasOwn(result, resultName) ? + merger.merge(result[resultName], pruned) + : pruned; + } + + return; + } + + // Note: we do NOT set `changed` to true anywhere in this branch of the + // conditional, despite the fact that we might have encountered a + // partial @defer boundary. Dropping a fragment does not guarantee keys + // are actually dropped which can happen when overlapping sibling + // selections contribute to the construction of the object. The final + // Object.keys(result).length check actually detects whether keys were + // dropped or not. + + const fragment = getFragmentFromSelection(selection, lookupFragment); + + if ( + fragment && + policies.fragmentMatches(fragment, data.__typename) && + !boundaries.has(selection) && + !context.deferInfo?.peekArray(path) + ) { + fragment.selectionSet.selections.forEach(workSet.add, workSet); + } + }); + + if (Object.keys(result).length !== Object.keys(data).length) { + changed = true; + } else if (changed && boundaries.hasSelections()) { + // Overlapping siblings may rebuild the same fields under a new object + // identity (e.g. changed === true) after a partial @defer is skipped. + // We perform a deep equality check to verify whether anything was + // actually dropped by the partial @defer fragment. + changed = !equal(result, data); + } + + return changed ? result : data; + } + + private prunePartialStreamArrayImpl({ + field, + array, + boundaries, + context, + path, + }: PruneArrayOptions) { + if (!boundaries) return array; + + let changed = false; + let pruned: any[] = []; + + const state = context.streamInfo?.peekArray(path)?.state; + const length = Math.min( + array.length, + state?.truncate ? state.streamPosition : Number.MAX_SAFE_INTEGER + ); + + for (let i = 0; i < length; i++) { + const item = array[i]; + + let prunedItem = item; + const boundary = boundaries.getChild(i); + + if (boundary?.has(field)) { + // The presence of streamInfo determines how we truncate partial + // stream arrays. Stream info is only given to cache.diff during + // in-flight requests so we want keep items in the array equal to the + // total that have streamed in (this is represented by streamPosition + // above). For all other cache reads, partial stream boundaries are + // pruned back to an empty array. + if (state) { + state.truncate = true; + pruned = pruned.slice(0, state.streamPosition); + } else { + pruned = []; + } + + break; + } + + if (Array.isArray(item)) { + prunedItem = this.prunePartialStreamArray({ + field, + array: item, + boundaries: boundaries.getChild(i), + context, + path: path.concat(i), + }); + } else if (field.selectionSet) { + prunedItem = this.prunePartialBoundaries({ + data: item, + selectionSet: field.selectionSet, + boundaries: boundaries.getChild(i), + context, + path: path.concat(i), + }); + } + + pruned.push(prunedItem); + changed ||= prunedItem !== item; + } + + changed ||= pruned.length !== array.length; + + return changed ? pruned : array; + } } function firstMissing(tree: MissingTree): string | undefined { @@ -529,3 +1024,107 @@ function assertSelectionSetForIdValue( }); } } + +// We deliberately leave `returnPartialData` out of `executeSelectionSet`'s +// cache key. `isFresh` runs during writes and cannot provide a reliable value +// for this option, so including it would prevent a reliable cache hit. +// +// When `returnPartialData` is false, `diffQueryAgainstStore` prunes data from +// partial @defer boundaries after reading the cached result. `PartialBoundaries` +// records the selection paths needed for that pass, including empty nodes along +// a path. Overlapping non-deferred selections must still be rebuilt so fields +// contributed only by a partial deferred sibling are removed. The prune pass +// can then skip unrelated result branches. +class PartialBoundaries { + private selections = new Set(); + private children = new Map(); + + add(selection: SelectionNode) { + this.selections.add(selection); + + return this; + } + + has(selection: SelectionNode) { + return this.selections.has(selection); + } + + hasSelections() { + return this.selections.size > 0; + } + + getChild(key: string | number) { + return this.children.get(key); + } + + clone() { + return new PartialBoundaries().merge(this); + } + + set(key: string | number, boundary: PartialBoundaries) { + const child = this.getChild(key); + + this.children.set( + key, + // Create a new PartialBoundaries instance to avoid mutating any cached + // execResult partialBoundaries objects + child ? child.clone().merge(boundary) : boundary + ); + } + + merge(boundaries: PartialBoundaries) { + boundaries.selections.forEach((selection) => this.add(selection)); + boundaries.children.forEach((child, key) => this.set(key, child)); + + return this; + } +} + +// Describes the data state transitions that change the running state when it's +// combined with the next data state. Omitted object values represent +// "impossible" merges where the data state should remain the same. +const DATA_STATE_MERGES: Record< + DataState, + Partial> +> = { + empty: { + complete: "partial", + deferPartial: "partial", + streaming: "partial", + streamPartial: "partial", + }, + deferPartial: { + empty: "partial", + }, + streamPartial: { + deferPartial: "deferPartial", + empty: "partial", + }, + streaming: { + deferPartial: "deferPartial", + empty: "partial", + streamPartial: "streamPartial", + }, + complete: { + deferPartial: "deferPartial", + streaming: "streaming", + streamPartial: "streamPartial", + empty: "partial", + }, + partial: {}, +}; + +function mergeDataState( + current: DataState | undefined, + next: DataState +): DataState { + if (next === "partial") { + return "partial"; + } + + if (!current || current === next) { + return next; + } + + return DATA_STATE_MERGES[current][next] || current; +} diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index b010351130d..783439fd6da 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -7,8 +7,11 @@ import type { StoreValue, } from "@apollo/client/utilities"; import type { + DeferInfoTrie, ExtensionsWithStreamInfo, + handleIncrementalSymbol, RemoveIndexSignature, + StreamInfoTrie, } from "@apollo/client/utilities/internal"; import type { ApolloCache, Transaction } from "../core/cache.js"; @@ -131,8 +134,15 @@ export type ReadQueryOptions = { config?: ApolloReducerConfig; }; +/** @internal */ +export interface DiffIncrementalInfo { + streamInfo?: StreamInfoTrie; + deferInfo?: DeferInfoTrie; +} + export type DiffQueryAgainstStoreOptions = ReadQueryOptions & { returnPartialData?: boolean; + [handleIncrementalSymbol]?: true | DiffIncrementalInfo; }; export type ApolloReducerConfig = { diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 4ae9c912acd..aa7fe07ca1f 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -33,6 +33,7 @@ import { isArray, isField, isNonEmptyArray, + isStreamField, makeReference, resultKeyNameFromField, shouldInclude, @@ -399,7 +400,7 @@ export class StoreWriter { } else if ( context.extensions?.[streamInfoSymbol] && Array.isArray(incomingValue) && - hasStreamDirective(field) + isStreamField(field, context.variables) ) { childTree.info = { field, @@ -824,13 +825,6 @@ function mergeMergeTrees( return merged; } -function hasStreamDirective(field: FieldNode): boolean { - return ( - !!field.directives && - field.directives.some((directive) => directive.name.value === "stream") - ); -} - function mergeTreeIsEmpty(tree: MergeTree | undefined): boolean { return !tree || !(tree.info || tree.map.size); } diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 7610451f2f7..a9713f49528 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -1,39 +1,30 @@ import { equal } from "@wry/equality"; -import type { - DocumentNode, - FormattedExecutionResult, - SelectionSetNode, -} from "graphql"; +import { Trie } from "@wry/trie"; +import type { DocumentNode, FormattedExecutionResult } from "graphql"; -import type { ApolloCache, Cache, MissingTree } from "@apollo/client/cache"; -import type { IgnoreModifier } from "@apollo/client/cache"; +import type { + ApolloCache, + Cache, + DiffIncrementalInfo, + IgnoreModifier, + InMemoryCache, +} from "@apollo/client/cache"; import type { Incremental } from "@apollo/client/incremental"; import type { ApolloLink } from "@apollo/client/link"; import type { Unmasked } from "@apollo/client/masking"; import type { DeepPartial } from "@apollo/client/utilities"; -import type { - ExtensionsWithStreamInfo, - FieldMap, - FragmentMap, -} from "@apollo/client/utilities/internal"; +import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal"; import { - collectSiblingFields, - createFragmentMap, - getFragmentDefinitions, - getFragmentFromSelection, - getMainDefinition, getOperationName, graphQLResultHasError, + handleIncrementalSymbol, hasDirectives, - isDeferredFragment, - isField, - isTypenameField, - resultKeyNameFromField, streamInfoSymbol, } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; import type { ApolloClient } from "./ApolloClient.js"; +import { NetworkStatus } from "./networkStatus.js"; import type { ObservableQuery } from "./ObservableQuery.js"; import type { QueryManager } from "./QueryManager.js"; import type { @@ -47,7 +38,10 @@ import type { OperationVariables, TypedDocumentNode, } from "./types.js"; -import type { ErrorPolicy } from "./watchQueryOptions.js"; +import type { + ErrorPolicy, + WatchQueryFetchPolicy, +} from "./watchQueryOptions.js"; type UpdateQueries = ApolloClient.MutateOptions< TData, @@ -237,7 +231,12 @@ export class QueryInfo< errorPolicy, cacheWriteBehavior, returnPartialData, - }: OperationInfo + fetchPolicy, + networkStatus, + }: OperationInfo & { + fetchPolicy: WatchQueryFetchPolicy; + networkStatus: NetworkStatus; + } ): MarkQueryResult< DataValue.Complete | DataValue.Streaming, ExtensionsWithStreamInfo @@ -245,7 +244,6 @@ export class QueryInfo< const diffOptions = { query, variables, - returnPartialData: true, optimistic: true, }; @@ -255,7 +253,15 @@ export class QueryInfo< const skipCache = cacheWriteBehavior === CacheWriteBehavior.FORBID; const lastDiff = - skipCache ? undefined : this.cache.diff(diffOptions); + skipCache ? undefined : ( + this.getDiff({ + ...diffOptions, + // Always request partial data to ensure the network incremental + // result is merged with all existing data (especially true to + // maintain @stream arrays with partial list items in the right order) + returnPartialData: true, + }) + ); const incrementalResult = this.maybeHandleIncrementalResult( lastDiff?.result, @@ -265,26 +271,37 @@ export class QueryInfo< let result: MarkQueryResult = { ...incrementalResult, - dataState: - incrementalResult.data == null ? "empty" - : this.hasNext ? "streaming" - : "complete", + dataState: incrementalResult.data == null ? "empty" : "complete", }; if (skipCache) { - return { - ...result, - dataState: - result.data == null ? "empty" - // Ww can simplify the checks for no-cache queries because the - // result is purely server driven which means we can assume a - // well-formed GraphQL response. In this case, `streaming` only - // makes sense if we are using the `@defer` directive and there are - // more chunks still streaming (i.e. this.hasNext is true). Purely - // `@stream` queries should always be complete. - : this.hasNext && hasDirectives(["defer"], query) ? "streaming" - : "complete", - }; + const hasPendingDefer = this.incremental?.pending?.some( + (pending) => this.incremental?.getPendingType?.(pending.id) === "defer" + ); + + if ( + hasPendingDefer || + // The Defer20220824Handler cannot track pending/completed incremental + // chunks due to its data format so we naively set dataState to + // streaming if we are still processing chunks. The only case where + // streaming is incorrect and should actually be complete is when + // both a @defer and @stream boundary is present and the @defer chunk + // has completed before the `@stream` array. + // + // Assigning the naive "streaming" value avoids a much more expensive + // pass over `result.data` that would otherwise need to traverse the + // selection sets and evaluate the data object at each defer boundary + // to see if it fulfills the selection set. For such a narrow case where + // its incorrect on a format that is now outdated is not worth the + // fix so we are ok with reporting a `streaming` here. + (!this.incremental?.pending && + this.hasNext && + hasDirectives(["defer"], query)) + ) { + result.dataState = "streaming"; + } + + return result; } if (shouldWriteResult(result, errorPolicy)) { @@ -305,7 +322,9 @@ export class QueryInfo< } }, update: (cache) => { - if (this.shouldWrite(result, variables)) { + const shouldWrite = this.shouldWrite(result, variables); + + if (shouldWrite) { cache.writeQuery({ query, data: result.data as Unmasked, @@ -366,35 +385,25 @@ export class QueryInfo< // re-reading the latest data with cache.diff, below. } - const diff = cache.diff(diffOptions); - - // If we're allowed to write to the cache, and we can read a - // complete result from the cache, update result.data to be the - // result from the cache, rather than the raw network result. - // Set without setDiff to avoid triggering a notify call, since - // we have other ways of notifying for this result. - if (diff.complete) { - result = { - ...result, - data: diff.result, - dataState: "complete", - }; - } else if (this.hasNext && diff.result !== null) { - const isPartial = isStreamingPartial(diff, query, variables); - - // If we tolerate partial results always apply `diff.result` to - // ensure we return the result of any transforms in cache read - // functions or custom scalars. If we don't tolerate partial - // results, we only want to apply the diff result if the only hole - // in the data is at a defer boundary (e.g. - // `diff.complete === false && isStreamingPartial === false`) - if (returnPartialData || !isPartial) { - result = { - ...result, - data: diff.result, - dataState: isPartial ? "partial" : "streaming", - }; - } + const isNetworkOnly = + fetchPolicy === "network-only" && + networkStatus !== NetworkStatus.refetch; + + const { dataState, result: diffResult } = this.getDiff( + { + ...diffOptions, + // Never deliver partial data for network-only requests + returnPartialData: returnPartialData && !isNetworkOnly, + }, + this.getIncrementalInfo(result, { isNetworkOnly }) + ); + + if ( + dataState === "complete" || + (returnPartialData && dataState === "partial" && shouldWrite) || + (this.hasNext && dataState === "streaming") + ) { + result = { ...result, data: diffResult, dataState }; } }, }); @@ -405,6 +414,60 @@ export class QueryInfo< return result; } + private getIncrementalInfo( + result: MarkQueryResult, + { isNetworkOnly }: { isNetworkOnly: boolean } + ) { + const pending = this.incremental?.pending ?? []; + const streamInfo = result.extensions?.[streamInfoSymbol]?.deref(); + const incrementalInfo: DiffIncrementalInfo = { streamInfo }; + + // We don't want to deliver stream items or complete defer boundaries + // for a network-only request if they haven't yet streamed from the + // network. We record all the still-pending paths so that cache.diff + // can prune complete defer/stream boundaries at those paths. + if (isNetworkOnly) { + for (const item of pending) { + const type = this.incremental?.getPendingType?.(item.id); + + if (type === "defer") { + incrementalInfo.deferInfo ||= new Trie(true, () => true); + incrementalInfo.deferInfo.lookupArray(item.path as any[]); + } else if (streamInfo && type === "stream") { + streamInfo.lookupArray(item.path as any[]).state.truncate = true; + } + } + } + + return incrementalInfo; + } + + private getDiff( + options: Cache.DiffOptions, + incrementalInfo?: DiffIncrementalInfo + ): Cache.InternalDiffResultWithDataState { + if ((this.cache as any)[handleIncrementalSymbol]) { + return (this.cache as unknown as InMemoryCache).diff({ + ...options, + [handleIncrementalSymbol]: incrementalInfo || true, + }); + } + + // returnPartialData is overridden for backwards compatibility with caches + // that don't handle incremental results. Without this, in-flight + // incremental cache data would come back null when returnPartialData is + // false due to the partial result. + const diff = this.cache.diff({ ...options, returnPartialData: true }); + + return { + ...diff, + dataState: + diff.complete ? "complete" + : diff.result === null ? "empty" + : "partial", + } as Cache.InternalDiffResultWithDataState; + } + public markMutationResult( incoming: ApolloLink.Result, mutation: OperationInfo< @@ -711,189 +774,3 @@ function shouldWriteResult( } return writeWithErrors; } - -function isStreamingPartial( - diff: Cache.DiffResult, - document: DocumentNode, - variables: OperationVariables -) { - if (!diff.missing) return false; - - const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); - - return isPartialInSelectionSet( - getMainDefinition(document).selectionSet, - diff.missing.missing, - { fragmentMap, variables } - ); -} - -interface StreamingPartialContext { - fragmentMap: FragmentMap; - variables: OperationVariables; -} - -function isPartialInSelectionSet( - selectionSet: SelectionSetNode, - missingTree: MissingTree | undefined, - context: StreamingPartialContext -): boolean { - if (typeof missingTree === "string") return true; - if (missingTree === undefined) return false; - - const missingKeys = Object.keys(missingTree); - if (missingKeys.length > 0 && missingKeys.every(isArrayIndex)) { - return missingKeys.some((key) => { - if (typeof missingTree[key] === "string") return true; - - return isPartialInSelectionSet(selectionSet, missingTree[key], context); - }); - } - - const { fragmentMap, variables } = context; - - for (const selection of selectionSet.selections) { - if (isField(selection)) { - if (isTypenameField(selection)) continue; - - const missing = missingTree[resultKeyNameFromField(selection)]; - // If a missing entry is absent, we have a value for this field - if (missing === undefined) continue; - - if ( - !selection.selectionSet || - isPartialInSelectionSet(selection.selectionSet, missing, context) - ) { - return true; - } - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - if (!fragment) continue; - - if (isDeferredFragment(selection, variables)) { - // We need to know the non-deferred fields that might overlap with the - // deferred selection set so that we accurately report the right - // dataState when all the non-deferred fields are satisfied and only the - // selections inside the fragment are missing. - // For example: - // - // { - // recipient { - // name - // } - // ... @defer { - // recipient { - // name - // email - // } - // } - // } - // - // dataState should be `streaming`, not `partial` if `name` is present, - // but `email` is absent since `name` is not deferred. - if ( - isPartialDeferBoundary( - fragment.selectionSet, - missingTree, - collectSiblingFields(selectionSet, { - ...context, - exclude: selection, - }), - fragmentMap - ) - ) { - return true; - } - } else if ( - isPartialInSelectionSet(fragment.selectionSet, missingTree, context) - ) { - return true; - } - } - } - - return false; -} - -type MissingObject = Exclude; - -function isPartialDeferBoundary( - selectionSet: SelectionSetNode, - missing: MissingObject, - nonDeferredFields: FieldMap | undefined, - fragmentMap: FragmentMap -): boolean { - // This flag tracks whether all fields in this selection set should contain - // values or should all be missing. A defer boundary is only partial if some - // fields are missing. The first selection node will set this value and all - // sibling fields should match after that. As soon as we get a mismatch, we - // have a partial defer boundary. - let shouldContainValues: boolean | undefined; - - for (const selection of selectionSet.selections) { - if (isField(selection)) { - if (isTypenameField(selection)) continue; - - const name = resultKeyNameFromField(selection); - const nonDeferredField = nonDeferredFields?.[name]; - - // If this field is not exclusive to this selection set, we don't care - // what the value is as far as the defer boundary is concerned. Ignore it - // and continue checking other siblings. - if (nonDeferredField === true) continue; - - // The value of missing means different things: - // - undefined: The field is fully satisfied (i.e. it has a value for the field) - // - string: the field is fully unsatisfied (no scalar value, or object is - // missing all fields) - // - object: The field has a selection set and is partially satisfied. - const missingField = missing[name]; - - if (typeof missingField !== "object") { - const hasValue = missingField === undefined; - - if (shouldContainValues === undefined) { - shouldContainValues = hasValue; - } - - if (hasValue !== shouldContainValues) { - return true; - } - - continue; - } - - if ( - selection.selectionSet && - isPartialDeferBoundary( - selection.selectionSet, - missingField, - nonDeferredField, - fragmentMap - ) - ) { - return true; - } - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - if (!fragment) continue; - - if ( - isPartialDeferBoundary( - fragment.selectionSet, - missing, - nonDeferredFields, - fragmentMap - ) - ) { - return true; - } - } - } - - return false; -} - -function isArrayIndex(key: string) { - return /^\d+$/.test(key); -} diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index f0b47cea92f..6ad1dd2699c 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1036,6 +1036,7 @@ export class QueryManager { context: DefaultContext | undefined; fetchPolicy: WatchQueryFetchPolicy; errorPolicy: ErrorPolicy; + networkStatus: NetworkStatus; returnPartialData: boolean | undefined; }, { @@ -1200,6 +1201,7 @@ export class QueryManager { fetchPolicy, errorPolicy, returnPartialData, + networkStatus, notifyOnNetworkStatusChange, context, }); @@ -1546,11 +1548,13 @@ export class QueryManager { errorPolicy, returnPartialData, context, + networkStatus, }: { query: DocumentNode | TypedDocumentNode; variables: TVariables; fetchPolicy: WatchQueryFetchPolicy; errorPolicy: ErrorPolicy; + networkStatus: NetworkStatus; returnPartialData?: boolean; context?: DefaultContext; }, @@ -1680,6 +1684,7 @@ export class QueryManager { fetchPolicy, errorPolicy, returnPartialData, + networkStatus, }, { cacheWriteBehavior, diff --git a/src/core/__tests__/client.watchQuery/defer20220824.test.ts b/src/core/__tests__/client.watchQuery/defer20220824.test.ts index 91fc60ded66..7a7a17274da 100644 --- a/src/core/__tests__/client.watchQuery/defer20220824.test.ts +++ b/src/core/__tests__/client.watchQuery/defer20220824.test.ts @@ -166,7 +166,7 @@ test("deduplicates queries as long as a query still has deferred chunks", async }); it.each([["cache-first"], ["no-cache"]] as const)( - "correctly merges deleted rows when receiving a deferred payload", + "correctly merges deleted rows when receiving a deferred payload with %s fetch policy", async (fetchPolicy) => { const query = gql` query Characters { diff --git a/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts index 24e13be17f0..a4544d353e2 100644 --- a/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts @@ -381,112 +381,7 @@ it.each([["cache-first"], ["no-cache"]] as const)( } ); -test.failing( - 'returns non-deferred cached data with a "cache-first" fetch policy and returnPartialData: false', - async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - email - } - } - } - } - `; - - const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = - mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Suppress console - // warnings to avoid unnecessary noise in the test. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { - email: "test@example.com", - }, - }, - }, - }); - } - - const client = new ApolloClient({ - cache, - link: httpLink, - incrementalHandler: new GraphQL17Alpha9Handler(), - }); - - const stream = new ObservableStream(client.watchQuery({ query })); - - await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - - enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [{ id: "0", path: ["greeting"] }], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - enqueueSubsequentChunk({ - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - id: "0", - }, - ], - completed: [{ id: "0" }], - hasNext: false, - }); - - await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - partial: false, - }); - - await expect(stream).not.toEmitAnything(); - } -); - -test('returns partial non-deferred cached data with a "cache-first" fetch policy and returnPartialData', async () => { +test('returns non-deferred cached data with a "cache-first" fetch policy and returnPartialData: false', async () => { const query = gql` query { greeting { @@ -494,6 +389,7 @@ test('returns partial non-deferred cached data with a "cache-first" fetch policy ... on Greeting @defer { recipient { name + email } } } @@ -525,22 +421,11 @@ test('returns partial non-deferred cached data with a "cache-first" fetch policy incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Cached hello", - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -570,7 +455,11 @@ test('returns partial non-deferred cached data with a "cache-first" fetch policy { data: { __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, + recipient: { + name: "Alice", + email: "alice@example.com", + __typename: "Person", + }, }, id: "0", }, @@ -584,7 +473,11 @@ test('returns partial non-deferred cached data with a "cache-first" fetch policy greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, }, dataState: "complete", @@ -596,20 +489,19 @@ test('returns partial non-deferred cached data with a "cache-first" fetch policy await expect(stream).not.toEmitAnything(); }); -test('returns partial deferred cached data as "partial" while streaming with a "cache-first" fetch policy and returnPartialData', async () => { - // Suppress expected missing field warning when writing partial value after - // first chunk - using _ = spyOnConsole("error"); +test("does not return incomplete cached fields inside a deferred named fragment spread with returnPartialData: false", async () => { const query = gql` query { greeting { message - ... on Greeting @defer { - recipient { - name - email - } - } + ...GreetingRecipient @defer + } + } + + fragment GreetingRecipient on Greeting { + recipient { + name + email } } `; @@ -618,15 +510,18 @@ test('returns partial deferred cached data as "partial" while streaming with a " mockDeferStreamGraphQL17Alpha9(); const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, }, - }, - }); + }); + } const client = new ApolloClient({ cache, @@ -634,22 +529,11 @@ test('returns partial deferred cached data as "partial" while streaming with a " incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -662,14 +546,13 @@ test('returns partial deferred cached data as "partial" while streaming with a " }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -679,11 +562,10 @@ test('returns partial deferred cached data as "partial" while streaming with a " incremental: [ { data: { - __typename: "Greeting", recipient: { + __typename: "Person", name: "Alice", email: "alice@example.com", - __typename: "Person", }, }, id: "0", @@ -714,14 +596,17 @@ test('returns partial deferred cached data as "partial" while streaming with a " await expect(stream).not.toEmitAnything(); }); -test("emits empty then streaming results for deferred queries with no data in the cache and returnPartialData", async () => { +test("does not surface incomplete cached fields from nested fragments inside a `@defer` boundary with returnPartialData: false", async () => { const query = gql` query { greeting { message ... on Greeting @defer { - recipient { - name + ... on Greeting { + recipient { + name + } + signature } } } @@ -730,20 +615,28 @@ test("emits empty then streaming results for deferred queries with no data in th const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -776,11 +669,8 @@ test("emits empty then streaming results for deferred queries with no data in th incremental: [ { data: { - __typename: "Greeting", - recipient: { - name: "Alice", - __typename: "Person", - }, + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, id: "0", }, @@ -795,6 +685,7 @@ test("emits empty then streaming results for deferred queries with no data in th __typename: "Greeting", message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -806,43 +697,49 @@ test("emits empty then streaming results for deferred queries with no data in th await expect(stream).not.toEmitAnything(); }); -test('reports overlapping deferred and non-deferred fields as "streaming" when only the deferred-only fields are missing', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test("does not surface incomplete cached fields from a named fragment spread inside a `@defer` boundary with returnPartialData: false", async () => { const query = gql` query { greeting { message - recipient { - name - } ... on Greeting @defer { - recipient { - name - email - } + ...DeferredGreeting } } } + + fragment DeferredGreeting on Greeting { + recipient { + name + } + signature + } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -853,26 +750,16 @@ test('reports overlapping deferred and non-deferred fields as "streaming" when o }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); - // `recipient.name` is guaranteed by the non-deferred selection; only - // `recipient.email` is deferred and still in flight, so the result is - // reported as streaming rather than partial. await expect(stream).toEmitTypedValue({ data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -885,11 +772,10 @@ test('reports overlapping deferred and non-deferred fields as "streaming" when o incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, id: "0", - subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -901,11 +787,8 @@ test('reports overlapping deferred and non-deferred fields as "streaming" when o greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -917,10 +800,7 @@ test('reports overlapping deferred and non-deferred fields as "streaming" when o await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when non-deferred fields for the same response key are split across sibling selection sets', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test("does not surface incomplete cached defer-only fields under an overlapping parent with returnPartialData: false", async () => { const query = gql` query { greeting { @@ -928,14 +808,11 @@ test('reports "streaming" when non-deferred fields for the same response key are recipient { name } - recipient { - email - } ... on Greeting @defer { recipient { name email - id + phone } } } @@ -944,19 +821,32 @@ test('reports "streaming" when non-deferred fields for the same response key are const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Cached Alice", + email: "cached@example.com", + }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -971,11 +861,7 @@ test('reports "streaming" when non-deferred fields for the same response key are greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }, pending: [{ id: "0", path: ["greeting"] }], @@ -987,11 +873,7 @@ test('reports "streaming" when non-deferred fields for the same response key are greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -1005,7 +887,8 @@ test('reports "streaming" when non-deferred fields for the same response key are { data: { __typename: "Person", - id: "1", + email: "alice@example.com", + phone: "555-0100", }, id: "0", subPath: ["recipient"], @@ -1024,7 +907,7 @@ test('reports "streaming" when non-deferred fields for the same response key are __typename: "Person", name: "Alice", email: "alice@example.com", - id: "1", + phone: "555-0100", }, }, }, @@ -1037,27 +920,16 @@ test('reports "streaming" when non-deferred fields for the same response key are await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when non-deferred fields for the same response key come from a field and a fragment', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test("does not surface incomplete cached fields inside a list item `@defer` boundary while sibling items are clean defer gaps with returnPartialData: false", async () => { const query = gql` query { - greeting { - message - recipient { - name - } - ... on Greeting { - recipient { - email - } - } - ... on Greeting @defer { - recipient { - name + person { + id + friends { + id + ... @defer { email - id + phone } } } @@ -1066,19 +938,39 @@ test('reports "streaming" when non-deferred fields for the same response key com const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "cached-leia@example.com", + }, + { + __typename: "Person", + id: "3", + }, + ], + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -1090,30 +982,31 @@ test('reports "streaming" when non-deferred fields for the same response key com enqueueInitialChunk({ data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], }, }, - pending: [{ id: "0", path: ["greeting"] }], + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], }, }), dataState: "streaming", @@ -1127,27 +1020,43 @@ test('reports "streaming" when non-deferred fields for the same response key com { data: { __typename: "Person", - id: "1", + email: "leia@example.com", + phone: "555-0102", }, id: "0", - subPath: ["recipient"], + }, + { + data: { + __typename: "Person", + email: "han@example.com", + phone: "555-0103", + }, + id: "1", }, ], - completed: [{ id: "0" }], + completed: [{ id: "0" }, { id: "1" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - id: "1", - }, + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "leia@example.com", + phone: "555-0102", + }, + { + __typename: "Person", + id: "3", + email: "han@example.com", + phone: "555-0103", + }, + ], }, }, dataState: "complete", @@ -1159,36 +1068,18 @@ test('reports "streaming" when non-deferred fields for the same response key com await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when nested non-deferred fields are split across sibling selection sets', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test("does not surface incomplete cached fields inside a later sibling `@defer` boundary while an earlier sibling is still pending with returnPartialData: false", async () => { const query = gql` query { greeting { message - recipient { - name - bestFriend { - name - } - } - recipient { - email - bestFriend { - email - } + ... @defer { + signature } - ... on Greeting @defer { + ... @defer { recipient { name email - id - bestFriend { - name - email - phone - } } } } @@ -1197,19 +1088,28 @@ test('reports "streaming" when nested non-deferred fields are split across sibli const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -1220,29 +1120,44 @@ test('reports "streaming" when nested non-deferred fields are split across sibli }); enqueueInitialChunk({ - data: { + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["greeting"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - bestFriend: { + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { __typename: "Person", - name: "Bob", - email: "bob@example.com", + name: "Alice", + email: "alice@example.com", }, }, + id: "1", }, - }, - pending: [{ id: "0", path: ["greeting"] }], + ], + completed: [{ id: "1" }], hasNext: true, }); - // All non-deferred paths are present, including nested `bestFriend` fields - // split across sibling selections. Only deferred-only `id` and - // `bestFriend.phone` are still missing. await expect(stream).toEmitTypedValue({ data: markAsStreaming({ greeting: { @@ -1252,11 +1167,6 @@ test('reports "streaming" when nested non-deferred fields are split across sibli __typename: "Person", name: "Alice", email: "alice@example.com", - bestFriend: { - __typename: "Person", - name: "Bob", - email: "bob@example.com", - }, }, }, }), @@ -1270,15 +1180,9 @@ test('reports "streaming" when nested non-deferred fields are split across sibli incremental: [ { data: { - __typename: "Person", - id: "1", - bestFriend: { - __typename: "Person", - phone: "555-0100", - }, + signature: "From Apollo", }, id: "0", - subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -1294,14 +1198,8 @@ test('reports "streaming" when nested non-deferred fields are split across sibli __typename: "Person", name: "Alice", email: "alice@example.com", - id: "1", - bestFriend: { - __typename: "Person", - name: "Bob", - email: "bob@example.com", - phone: "555-0100", - }, }, + signature: "From Apollo", }, }, dataState: "complete", @@ -1313,18 +1211,24 @@ test('reports "streaming" when nested non-deferred fields are split across sibli await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when one of multiple sibling `@defer` fragments has fully arrived and another is still pending', async () => { +test("does not surface incomplete cached deep defer-only fields under an overlapped path with returnPartialData: false", async () => { const query = gql` query { greeting { message - ... @defer { - recipient { - name + recipient { + address { + city } } - ... @defer { - signature + ... on Greeting @defer { + recipient { + address { + city + postalCode + line2 + } + } } } } @@ -1332,16 +1236,35 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "Cached City", + postalCode: "00000", + }, + }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ data: undefined, @@ -1352,41 +1275,17 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [ - { id: "0", path: ["greeting"] }, - { id: "1", path: ["greeting"] }, - ], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - // One deferred fragment completes while the other is still in flight. The - // arrived fragment is fully satisfied (`recipient`); only `signature` remains - // as a clean defer gap. That must stay "streaming", not flip to "partial" - // just because some deferred selection is now present in the result. - enqueueSubsequentChunk({ - incremental: [ - { - data: { - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + address: { __typename: "Address", city: "New York" }, }, - id: "0", }, - ], - completed: [{ id: "0" }], + }, + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); @@ -1395,7 +1294,13 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + }, + }, }, }), dataState: "streaming", @@ -1408,12 +1313,15 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu incremental: [ { data: { - signature: "From Apollo", + __typename: "Address", + postalCode: "10001", + line2: "Apt 4", }, - id: "1", + id: "0", + subPath: ["recipient", "address"], }, ], - completed: [{ id: "1" }], + completed: [{ id: "0" }], hasNext: false, }); @@ -1422,8 +1330,15 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + postalCode: "10001", + line2: "Apt 4", + }, + }, }, }, dataState: "complete", @@ -1435,33 +1350,43 @@ test('reports "streaming" when one of multiple sibling `@defer` fragments has fu await expect(stream).not.toEmitAnything(); }); -test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" fetch policy and returnPartialData', async () => { +test("does not treat cached `__typename`-only data inside a `@defer` boundary as incomplete deferred fields with returnPartialData: false", async () => { const query = gql` query { greeting { message - ... on Greeting @defer(if: false) { + ... on Greeting @defer { recipient { name + email } } } } `; - const { httpLink, enqueueInitialChunk } = mockDeferStreamGraphQL17Alpha9(); + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); const cache = new InMemoryCache(); - // We are intentionally writing partial data to the cache. Suppress console - // warnings to avoid unnecessary noise in the test. { - using _consoleSpy = spyOnConsole("error"); + using _ = spyOnConsole("error"); cache.writeQuery({ - query, + query: gql` + query { + greeting { + message + recipient { + __typename + } + } + } + `, data: { greeting: { __typename: "Greeting", - message: "Cached hello", + message: "Cached", + recipient: { __typename: "Person" }, }, }, }); @@ -1473,22 +1398,11 @@ test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" incrementalHandler: new GraphQL17Alpha9Handler(), }); - const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) - ); + const stream = new ObservableStream(client.watchQuery({ query })); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Cached hello", - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -1499,10 +1413,39 @@ test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, }, }, - pending: [], + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], hasNext: false, }); @@ -1511,7 +1454,11 @@ test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, }, dataState: "complete", @@ -1523,28 +1470,41 @@ test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" await expect(stream).not.toEmitAnything(); }); -test('evaluates `@defer(if: $variable)` as non-deferred when the variable is false, reporting "streaming" while a sibling `@defer` is pending', async () => { +test('returns partial non-deferred cached data with a "cache-first" fetch policy and returnPartialData', async () => { const query = gql` - query ($shouldDefer: Boolean!) { + query { greeting { message - ... on Greeting @defer(if: $shouldDefer) { + ... on Greeting @defer { recipient { name } } - ... on Greeting @defer { - signature - } } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Suppress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -1552,27 +1512,26 @@ test('evaluates `@defer(if: $variable)` as non-deferred when the variable is fal const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, - variables: { shouldDefer: false }, }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - - enqueueInitialChunk({ data: { greeting: { __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + message: "Cached hello", }, }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); @@ -1582,7 +1541,6 @@ test('evaluates `@defer(if: $variable)` as non-deferred when the variable is fal greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -1596,7 +1554,7 @@ test('evaluates `@defer(if: $variable)` as non-deferred when the variable is fal { data: { __typename: "Greeting", - signature: "From Apollo", + recipient: { name: "Alice", __typename: "Person" }, }, id: "0", }, @@ -1611,7 +1569,6 @@ test('evaluates `@defer(if: $variable)` as non-deferred when the variable is fal __typename: "Greeting", message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", }, }, dataState: "complete", @@ -1623,28 +1580,40 @@ test('evaluates `@defer(if: $variable)` as non-deferred when the variable is fal await expect(stream).not.toEmitAnything(); }); -test('evaluates `@defer(if: $variable)` as deferred when the variable is true, reporting "streaming" while deferred fields are pending', async () => { +test('returns partial deferred cached data as "partial" while streaming with a "cache-first" fetch policy and returnPartialData', async () => { + // Suppress expected missing field warning when writing partial value after + // first chunk + using _ = spyOnConsole("error"); const query = gql` - query ($shouldDefer: Boolean!) { + query { greeting { message - ... on Greeting @defer(if: $shouldDefer) { + ... on Greeting @defer { recipient { name + email } } - ... on Greeting @defer { - signature - } } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -1652,14 +1621,19 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, - variables: { shouldDefer: true }, }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -1667,18 +1641,19 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r enqueueInitialChunk({ data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [ - { id: "0", path: ["greeting"] }, - { id: "1", path: ["greeting"] }, - ], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -1688,12 +1663,83 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r incremental: [ { data: { - recipient: { __typename: "Person", name: "Alice" }, + __typename: "Greeting", + recipient: { + name: "Alice", + email: "alice@example.com", + __typename: "Person", + }, }, id: "0", }, ], completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("emits empty then streaming results for deferred queries with no data in the cache and returnPartialData", 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 stream = new ObservableStream( + client.watchQuery({ + query, + fetchPolicy: "cache-first", + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); @@ -1702,7 +1748,6 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -1716,12 +1761,15 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r { data: { __typename: "Greeting", - signature: "From Apollo", + recipient: { + name: "Alice", + __typename: "Person", + }, }, - id: "1", + id: "0", }, ], - completed: [{ id: "1" }], + completed: [{ id: "0" }], hasNext: false, }); @@ -1731,7 +1779,6 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r __typename: "Greeting", message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", }, }, dataState: "complete", @@ -1743,18 +1790,16 @@ test('evaluates `@defer(if: $variable)` as deferred when the variable is true, r await expect(stream).not.toEmitAnything(); }); -test("evaluates `@defer(if: $variable)` for overlapping fields so disabled-defer selections stay non-deferred", async () => { +test('reports overlapping deferred and non-deferred fields as "streaming" when only the deferred-only fields are missing', async () => { // Suppress expected missing field write warnings since defer context is reset. using _ = spyOnConsole("error"); const query = gql` - query ($shouldDefer: Boolean!) { + query { greeting { message - ... on Greeting @defer(if: $shouldDefer) { - recipient { - name - } + recipient { + name } ... on Greeting @defer { recipient { @@ -1778,8 +1823,8 @@ test("evaluates `@defer(if: $variable)` for overlapping fields so disabled-defer const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, - variables: { shouldDefer: false }, }) ); @@ -1803,6 +1848,9 @@ test("evaluates `@defer(if: $variable)` for overlapping fields so disabled-defer hasNext: true, }); + // `recipient.name` is guaranteed by the non-deferred selection; only + // `recipient.email` is deferred and still in flight, so the result is + // reported as streaming rather than partial. await expect(stream).toEmitTypedValue({ data: markAsStreaming({ greeting: { @@ -1853,15 +1901,25 @@ test("evaluates `@defer(if: $variable)` for overlapping fields so disabled-defer await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" instead of "partial" when the only unfulfilled field of a deferred fragment is excluded by `@skip`', async () => { +test('reports "streaming" when non-deferred fields for the same response key are split across sibling selection sets', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + const query = gql` query { greeting { message + recipient { + name + } + recipient { + email + } ... on Greeting @defer { recipient { name - email @skip(if: true) + email + id } } } @@ -1870,25 +1928,9 @@ test('reports "streaming" instead of "partial" when the only unfulfilled field o const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Suppress 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, + cache: new InMemoryCache(), link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -1896,53 +1938,61 @@ test('reports "streaming" instead of "partial" when the only unfulfilled field o const stream = new ObservableStream( client.watchQuery({ query, - fetchPolicy: "cache-first", returnPartialData: true, }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, - }, - // `email` is excluded by `@skip`, so the deferred fragment is fully - // satisfied by the cached `name` and should report complete while streaming. - dataState: "complete", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, - partial: false, + partial: true, }); enqueueSubsequentChunk({ incremental: [ { data: { - recipient: { __typename: "Person", name: "Alice" }, + __typename: "Person", + id: "1", }, id: "0", + subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -1954,7 +2004,12 @@ test('reports "streaming" instead of "partial" when the only unfulfilled field o greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + id: "1", + }, }, }, dataState: "complete", @@ -1966,43 +2021,38 @@ test('reports "streaming" instead of "partial" when the only unfulfilled field o await expect(stream).not.toEmitAnything(); }); -test("reports the correct data state for `@defer` on a named fragment spread with partial cached data", async () => { - // Suppress expected missing field warning when writing partial value after - // first chunk +test('reports "streaming" when non-deferred fields for the same response key come from a field and a fragment', async () => { + // Suppress expected missing field write warnings since defer context is reset. using _ = spyOnConsole("error"); const query = gql` query { greeting { message - ...GreetingRecipient @defer - } - } - - fragment GreetingRecipient on Greeting { - recipient { - name - email + recipient { + name + } + ... on Greeting { + recipient { + email + } + } + ... on Greeting @defer { + recipient { + name + email + id + } + } } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); const client = new ApolloClient({ - cache, + cache: new InMemoryCache(), link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -2010,39 +2060,47 @@ test("reports the correct data state for `@defer` on a named fragment spread wit const stream = new ObservableStream( client.watchQuery({ query, - fetchPolicy: "cache-first", returnPartialData: true, }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -2052,13 +2110,11 @@ test("reports the correct data state for `@defer` on a named fragment spread wit incremental: [ { data: { - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + __typename: "Person", + id: "1", }, id: "0", + subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -2074,6 +2130,7 @@ test("reports the correct data state for `@defer` on a named fragment spread wit __typename: "Person", name: "Alice", email: "alice@example.com", + id: "1", }, }, }, @@ -2086,7 +2143,7 @@ test("reports the correct data state for `@defer` on a named fragment spread wit await expect(stream).not.toEmitAnything(); }); -test('reports overlapping fields contributed by a non-deferred fragment spread as "streaming"', async () => { +test('reports "streaming" when nested non-deferred fields are split across sibling selection sets', async () => { // Suppress expected missing field write warnings since defer context is reset. using _ = spyOnConsole("error"); @@ -2094,21 +2151,32 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a query { greeting { message - ...GreetingRecipient + recipient { + name + bestFriend { + name + } + } + recipient { + email + bestFriend { + email + } + } ... on Greeting @defer { recipient { name email + id + bestFriend { + name + email + phone + } } } } } - - fragment GreetingRecipient on Greeting { - recipient { - name - } - } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = @@ -2121,7 +2189,10 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ + query, + returnPartialData: true, + }) ); await expect(stream).toEmitTypedValue({ @@ -2137,19 +2208,40 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + bestFriend: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, }, }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); + // All non-deferred paths are present, including nested `bestFriend` fields + // split across sibling selections. Only deferred-only `id` and + // `bestFriend.phone` are still missing. await expect(stream).toEmitTypedValue({ data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + bestFriend: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, }, }), dataState: "streaming", @@ -2163,7 +2255,11 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a { data: { __typename: "Person", - email: "alice@example.com", + id: "1", + bestFriend: { + __typename: "Person", + phone: "555-0100", + }, }, id: "0", subPath: ["recipient"], @@ -2182,6 +2278,13 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a __typename: "Person", name: "Alice", email: "alice@example.com", + id: "1", + bestFriend: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + phone: "555-0100", + }, }, }, }, @@ -2194,24 +2297,18 @@ test('reports overlapping fields contributed by a non-deferred fragment spread a await expect(stream).not.toEmitAnything(); }); -test('reports overlapping fields contributed by a non-deferred inline fragment as "streaming"', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('reports "streaming" when one of multiple sibling `@defer` fragments has fully arrived and another is still pending', async () => { const query = gql` query { greeting { message - ... on Greeting { + ... @defer { recipient { name } } - ... on Greeting @defer { - recipient { - name - email - } + ... @defer { + signature } } } @@ -2239,14 +2336,41 @@ test('reports overlapping fields contributed by a non-deferred inline fragment a }); enqueueInitialChunk({ - data: { + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["greeting"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, }, - }, - pending: [{ id: "0", path: ["greeting"] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + // One deferred fragment completes while the other is still in flight. The + // arrived fragment is fully satisfied (`recipient`); only `signature` remains + // as a clean defer gap. That must stay "streaming", not flip to "partial" + // just because some deferred selection is now present in the result. + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], hasNext: true, }); @@ -2268,14 +2392,12 @@ test('reports overlapping fields contributed by a non-deferred inline fragment a incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + signature: "From Apollo", }, - id: "0", - subPath: ["recipient"], + id: "1", }, ], - completed: [{ id: "0" }], + completed: [{ id: "1" }], hasNext: false, }); @@ -2284,11 +2406,8 @@ test('reports overlapping fields contributed by a non-deferred inline fragment a greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -2300,40 +2419,37 @@ test('reports overlapping fields contributed by a non-deferred inline fragment a await expect(stream).not.toEmitAnything(); }); -test("treats nested fragments inside a `@defer` boundary when deciding if the fragment has started", async () => { - // Suppress expected missing field warning when writing partial value after - // first chunk - using _ = spyOnConsole("error"); - +test('treats `@defer(if: false)` as a non-deferred fragment with a "cache-first" fetch policy and returnPartialData', async () => { const query = gql` query { greeting { message - ... on Greeting @defer { - ... on Greeting { - recipient { - name - } - signature + ... on Greeting @defer(if: false) { + recipient { + name } } } } `; - const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = - mockDeferStreamGraphQL17Alpha9(); + const { httpLink, enqueueInitialChunk } = mockDeferStreamGraphQL17Alpha9(); const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, + // We are intentionally writing partial data to the cache. Suppress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + }, }, - }, - }); + }); + } const client = new ApolloClient({ cache, @@ -2342,14 +2458,18 @@ test("treats nested fragments inside a `@defer` boundary when deciding if the fr }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ + query, + fetchPolicy: "cache-first", + returnPartialData: true, + }) ); await expect(stream).toEmitTypedValue({ data: { greeting: { __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, + message: "Cached hello", }, }, dataState: "partial", @@ -2359,36 +2479,14 @@ test("treats nested fragments inside a `@defer` boundary when deciding if the fr }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [{ id: "0", path: ["greeting"] }], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, + recipient: { __typename: "Person", name: "Alice" }, }, }, - dataState: "partial", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - enqueueSubsequentChunk({ - incremental: [ - { - data: { - recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", - }, - id: "0", - }, - ], - completed: [{ id: "0" }], + pending: [], hasNext: false, }); @@ -2398,7 +2496,6 @@ test("treats nested fragments inside a `@defer` boundary when deciding if the fr __typename: "Greeting", message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", }, }, dataState: "complete", @@ -2410,81 +2507,69 @@ test("treats nested fragments inside a `@defer` boundary when deciding if the fr await expect(stream).not.toEmitAnything(); }); -test("treats a named fragment spread inside a `@defer` boundary when deciding if the fragment has started", async () => { - // Suppress expected missing field warning when writing partial value after - // first chunk - using _ = spyOnConsole("error"); - +test('evaluates `@defer(if: $variable)` as non-deferred when the variable is false, reporting "streaming" while a sibling `@defer` is pending', async () => { const query = gql` - query { + query ($shouldDefer: Boolean!) { greeting { message + ... on Greeting @defer(if: $shouldDefer) { + recipient { + name + } + } ... on Greeting @defer { - ...DeferredGreeting + signature } } } - - fragment DeferredGreeting on Greeting { - recipient { - name - } - signature - } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); const client = new ApolloClient({ - cache, + cache: new InMemoryCache(), link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ + query, + returnPartialData: true, + variables: { shouldDefer: false }, + }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, + recipient: { __typename: "Person", name: "Alice" }, }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -2494,7 +2579,7 @@ test("treats a named fragment spread inside a `@defer` boundary when deciding if incremental: [ { data: { - recipient: { __typename: "Person", name: "Alice" }, + __typename: "Greeting", signature: "From Apollo", }, id: "0", @@ -2522,49 +2607,28 @@ test("treats a named fragment spread inside a `@defer` boundary when deciding if await expect(stream).not.toEmitAnything(); }); -test('reports "partial" when a nested defer-only field is present under an overlapping parent while another defer-only field is still missing', async () => { - // Suppress expected missing field warning when writing partial value after - // first chunk - using _ = spyOnConsole("error"); - +test('evaluates `@defer(if: $variable)` as deferred when the variable is true, reporting "streaming" while deferred fields are pending', async () => { const query = gql` - query { + query ($shouldDefer: Boolean!) { greeting { message - recipient { - name - } - ... on Greeting @defer { + ... on Greeting @defer(if: $shouldDefer) { recipient { name - email - phone } } + ... on Greeting @defer { + signature + } } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { - __typename: "Person", - name: "Cached Alice", - email: "cached@example.com", - }, - }, - }, - }); const client = new ApolloClient({ - cache, + cache: new InMemoryCache(), link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -2573,51 +2637,59 @@ test('reports "partial" when a nested defer-only field is present under an overl client.watchQuery({ query, returnPartialData: true, + variables: { shouldDefer: true }, }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { - __typename: "Person", - name: "Cached Alice", - email: "cached@example.com", - }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["greeting"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", }, - }, - pending: [{ id: "0", path: ["greeting"] }], + ], + completed: [{ id: "0" }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "cached@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -2627,15 +2699,13 @@ test('reports "partial" when a nested defer-only field is present under an overl incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", - phone: "555-0100", + __typename: "Greeting", + signature: "From Apollo", }, - id: "0", - subPath: ["recipient"], + id: "1", }, ], - completed: [{ id: "0" }], + completed: [{ id: "1" }], hasNext: false, }); @@ -2644,12 +2714,8 @@ test('reports "partial" when a nested defer-only field is present under an overl greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - phone: "555-0100", - }, + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -2661,37 +2727,27 @@ test('reports "partial" when a nested defer-only field is present under an overl await expect(stream).not.toEmitAnything(); }); -test("merges non-deferred selections that contribute different subfields under the same response key", async () => { +test("evaluates `@defer(if: $variable)` for overlapping fields so disabled-defer selections stay non-deferred", async () => { // Suppress expected missing field write warnings since defer context is reset. using _ = spyOnConsole("error"); const query = gql` - query { + query ($shouldDefer: Boolean!) { greeting { message - ...RecipientName - ...RecipientId + ... on Greeting @defer(if: $shouldDefer) { + recipient { + name + } + } ... on Greeting @defer { recipient { - id name email } } } } - - fragment RecipientName on Greeting { - recipient { - name - } - } - - fragment RecipientId on Greeting { - recipient { - id - } - } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = @@ -2707,6 +2763,7 @@ test("merges non-deferred selections that contribute different subfields under t client.watchQuery({ query, returnPartialData: true, + variables: { shouldDefer: false }, }) ); @@ -2723,7 +2780,7 @@ test("merges non-deferred selections that contribute different subfields under t greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", id: "1", name: "Alice" }, + recipient: { __typename: "Person", name: "Alice" }, }, }, pending: [{ id: "0", path: ["greeting"] }], @@ -2735,7 +2792,7 @@ test("merges non-deferred selections that contribute different subfields under t greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", id: "1", name: "Alice" }, + recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -2766,7 +2823,6 @@ test("merges non-deferred selections that contribute different subfields under t message: "Hello world", recipient: { __typename: "Person", - id: "1", name: "Alice", email: "alice@example.com", }, @@ -2781,38 +2837,42 @@ test("merges non-deferred selections that contribute different subfields under t await expect(stream).not.toEmitAnything(); }); -test('reports overlapping fields from a fragment spread repeated at the same selection-set level as "streaming"', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('reports "streaming" instead of "partial" when the only unfulfilled field of a deferred fragment is excluded by `@skip`', async () => { const query = gql` query { greeting { message - recipient { - id - ...PersonName - ...PersonName - } ... on Greeting @defer { recipient { - ...PersonName - email + name + email @skip(if: true) } } } } - - fragment PersonName on Person { - name - } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Suppress 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: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -2820,55 +2880,53 @@ test('reports overlapping fields from a fragment spread repeated at the same sel const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", id: "1", name: "Alice" }, - }, - }, + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", id: "1", name: "Alice" }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, - }), - dataState: "streaming", + }, + // `email` is excluded by `@skip`, so the deferred fragment is fully + // satisfied by the cached `name` and should report complete while streaming. + dataState: "complete", loading: true, networkStatus: NetworkStatus.streaming, - partial: true, + partial: false, }); - // `name` was already delivered non-deferred via `PersonName`; only exclusive - // `email` arrives on the deferred pending (graphql.js uses `subPath`). enqueueSubsequentChunk({ incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + recipient: { __typename: "Person", name: "Alice" }, }, id: "0", - subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -2880,12 +2938,7 @@ test('reports overlapping fields from a fragment spread repeated at the same sel greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - id: "1", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }, dataState: "complete", @@ -2897,43 +2950,43 @@ test('reports overlapping fields from a fragment spread repeated at the same sel await expect(stream).not.toEmitAnything(); }); -test('reports overlapping fields from the same fragment spread used at different selection-set levels as "streaming"', async () => { - // Suppress expected missing field write warnings since defer context is reset. +test("reports the correct data state for `@defer` on a named fragment spread with partial cached data", async () => { + // Suppress expected missing field warning when writing partial value after + // first chunk using _ = spyOnConsole("error"); const query = gql` query { greeting { message - recipient { - ...PersonName - bestFriend { - ...PersonName - } - } - ... on Greeting @defer { - recipient { - ...PersonName - email - bestFriend { - ...PersonName - email - } - } - } + ...GreetingRecipient @defer } } - fragment PersonName on Person { - name + fragment GreetingRecipient on Greeting { + recipient { + name + email + } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -2941,47 +2994,39 @@ test('reports overlapping fields from the same fragment spread used at different const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - bestFriend: { __typename: "Person", name: "Bob" }, - }, - }, - }, + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - bestFriend: { __typename: "Person", name: "Bob" }, - }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, - }), - dataState: "streaming", + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -2991,19 +3036,13 @@ test('reports overlapping fields from the same fragment spread used at different incremental: [ { data: { - __typename: "Person", - email: "bob@example.com", - }, - id: "0", - subPath: ["recipient", "bestFriend"], - }, - { - data: { - __typename: "Person", - email: "alice@example.com", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, id: "0", - subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -3019,11 +3058,6 @@ test('reports overlapping fields from the same fragment spread used at different __typename: "Person", name: "Alice", email: "alice@example.com", - bestFriend: { - __typename: "Person", - name: "Bob", - email: "bob@example.com", - }, }, }, }, @@ -3036,19 +3070,29 @@ test('reports overlapping fields from the same fragment spread used at different await expect(stream).not.toEmitAnything(); }); -test('reports per-item `@defer` gaps on a list as "streaming" when every item is still pending', async () => { +test('reports overlapping fields contributed by a non-deferred fragment spread as "streaming"', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + const query = gql` query { - person { - id - friends { - id - ... @defer { + greeting { + message + ...GreetingRecipient + ... on Greeting @defer { + recipient { name + email } } } } + + fragment GreetingRecipient on Greeting { + recipient { + name + } + } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = @@ -3074,31 +3118,22 @@ test('reports per-item `@defer` gaps on a list as "streaming" when every item is enqueueInitialChunk({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2" }, - { __typename: "Person", id: "3" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, }, }, - pending: [ - { id: "0", path: ["person", "friends", 0] }, - { id: "1", path: ["person", "friends", 1] }, - ], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2" }, - { __typename: "Person", id: "3" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -3110,27 +3145,28 @@ test('reports per-item `@defer` gaps on a list as "streaming" when every item is enqueueSubsequentChunk({ incremental: [ { - data: { __typename: "Person", name: "Leia" }, + data: { + __typename: "Person", + email: "alice@example.com", + }, id: "0", - }, - { - data: { __typename: "Person", name: "Han" }, - id: "1", + subPath: ["recipient"], }, ], - completed: [{ id: "0" }, { id: "1" }], + completed: [{ id: "0" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2", name: "Leia" }, - { __typename: "Person", id: "3", name: "Han" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, }, dataState: "complete", @@ -3142,18 +3178,26 @@ test('reports per-item `@defer` gaps on a list as "streaming" when every item is await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when one list item\'s `@defer` has arrived and another item is still pending', async () => { +test('reports overlapping fields contributed by a non-deferred inline fragment as "streaming"', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + const query = gql` query { - person { - id - friends { - id - ... @defer { + greeting { + message + ... on Greeting { + recipient { name } } - } + ... on Greeting @defer { + recipient { + name + email + } + } + } } `; @@ -3180,31 +3224,22 @@ test('reports "streaming" when one list item\'s `@defer` has arrived and another enqueueInitialChunk({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2" }, - { __typename: "Person", id: "3" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, }, }, - pending: [ - { id: "0", path: ["person", "friends", 0] }, - { id: "1", path: ["person", "friends", 1] }, - ], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2" }, - { __typename: "Person", id: "3" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -3216,51 +3251,28 @@ test('reports "streaming" when one list item\'s `@defer` has arrived and another enqueueSubsequentChunk({ incremental: [ { - data: { __typename: "Person", name: "Leia" }, + data: { + __typename: "Person", + email: "alice@example.com", + }, id: "0", + subPath: ["recipient"], }, ], completed: [{ id: "0" }], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2", name: "Leia" }, - { __typename: "Person", id: "3" }, - ], - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - enqueueSubsequentChunk({ - incremental: [ - { - data: { __typename: "Person", name: "Han" }, - id: "1", - }, - ], - completed: [{ id: "1" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2", name: "Leia" }, - { __typename: "Person", id: "3", name: "Han" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, }, dataState: "complete", @@ -3272,23 +3284,21 @@ test('reports "streaming" when one list item\'s `@defer` has arrived and another await expect(stream).not.toEmitAnything(); }); -test('reports overlapping deferred and non-deferred list fields as "streaming" when only defer-only item fields are missing', async () => { - // Suppress expected missing field write warnings since defer context is reset. +test("treats nested fragments inside a `@defer` boundary when deciding if the fragment has started", async () => { + // Suppress expected missing field warning when writing partial value after + // first chunk using _ = spyOnConsole("error"); const query = gql` query { - person { - id - friends { - id - name - } - ... on Person @defer { - friends { - id - name - email + greeting { + message + ... on Greeting @defer { + ... on Greeting { + recipient { + name + } + signature } } } @@ -3297,9 +3307,20 @@ test('reports overlapping deferred and non-deferred list fields as "streaming" w const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -3309,40 +3330,33 @@ test('reports overlapping deferred and non-deferred list fields as "streaming" w ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2", name: "Leia" }, - { __typename: "Person", id: "3", name: "Han" }, - ], - }, - }, - pending: [{ id: "0", path: ["person"] }], + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2", name: "Leia" }, - { __typename: "Person", id: "3", name: "Han" }, - ], + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, }, - }), - dataState: "streaming", + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -3352,19 +3366,10 @@ test('reports overlapping deferred and non-deferred list fields as "streaming" w incremental: [ { data: { - __typename: "Person", - email: "leia@example.com", - }, - id: "0", - subPath: ["friends", 0], - }, - { - data: { - __typename: "Person", - email: "han@example.com", + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, id: "0", - subPath: ["friends", 1], }, ], completed: [{ id: "0" }], @@ -3373,23 +3378,11 @@ test('reports overlapping deferred and non-deferred list fields as "streaming" w await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { - __typename: "Person", - id: "2", - name: "Leia", - email: "leia@example.com", - }, - { - __typename: "Person", - id: "3", - name: "Han", - email: "han@example.com", - }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -3401,23 +3394,27 @@ test('reports overlapping deferred and non-deferred list fields as "streaming" w await expect(stream).not.toEmitAnything(); }); -test('reports "partial" when one list item has a started `@defer` that is still incomplete while other items are only clean defer gaps', async () => { - // Suppress expected missing field warning when writing partial list items. +test("treats a named fragment spread inside a `@defer` boundary when deciding if the fragment has started", async () => { + // Suppress expected missing field warning when writing partial value after + // first chunk using _ = spyOnConsole("error"); const query = gql` query { - person { - id - friends { - id - ... @defer { - email - phone - } + greeting { + message + ... on Greeting @defer { + ...DeferredGreeting } } } + + fragment DeferredGreeting on Greeting { + recipient { + name + } + signature + } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = @@ -3427,20 +3424,9 @@ test('reports "partial" when one list item has a started `@defer` that is still cache.writeQuery({ query, data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { - __typename: "Person", - id: "2", - email: "cached-leia@example.com", - }, - { - __typename: "Person", - id: "3", - }, - ], + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, }, }, }); @@ -3452,30 +3438,1590 @@ test('reports "partial" when one list item has a started `@defer` that is still }); const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) + client.watchQuery({ query, returnPartialData: true }) ); await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { - __typename: "Person", - id: "2", - email: "cached-leia@example.com", - }, - { - __typename: "Person", - id: "3", - }, - ], - }, - }, + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "partial" when a nested defer-only field is present under an overlapping parent while another defer-only field is still missing', async () => { + // Suppress expected missing field warning when writing partial value after + // first chunk + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + recipient { + name + } + ... on Greeting @defer { + recipient { + name + email + phone + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Cached Alice", + email: "cached@example.com", + }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Cached Alice", + email: "cached@example.com", + }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "cached@example.com", + }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + phone: "555-0100", + }, + id: "0", + subPath: ["recipient"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + phone: "555-0100", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("merges non-deferred selections that contribute different subfields under the same response key", async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ...RecipientName + ...RecipientId + ... on Greeting @defer { + recipient { + id + name + email + } + } + } + } + + fragment RecipientName on Greeting { + recipient { + name + } + } + + fragment RecipientId on Greeting { + recipient { + id + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "0", + subPath: ["recipient"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + id: "1", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports overlapping fields from a fragment spread repeated at the same selection-set level as "streaming"', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + recipient { + id + ...PersonName + ...PersonName + } + ... on Greeting @defer { + recipient { + ...PersonName + email + } + } + } + } + + fragment PersonName on Person { + name + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", id: "1", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + // `name` was already delivered non-deferred via `PersonName`; only exclusive + // `email` arrives on the deferred pending (graphql.js uses `subPath`). + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "0", + subPath: ["recipient"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + id: "1", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports overlapping fields from the same fragment spread used at different selection-set levels as "streaming"', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + recipient { + ...PersonName + bestFriend { + ...PersonName + } + } + ... on Greeting @defer { + recipient { + ...PersonName + email + bestFriend { + ...PersonName + email + } + } + } + } + } + + fragment PersonName on Person { + name + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + bestFriend: { __typename: "Person", name: "Bob" }, + }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + bestFriend: { __typename: "Person", name: "Bob" }, + }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "bob@example.com", + }, + id: "0", + subPath: ["recipient", "bestFriend"], + }, + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "0", + subPath: ["recipient"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + bestFriend: { + __typename: "Person", + name: "Bob", + email: "bob@example.com", + }, + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports per-item `@defer` gaps on a list as "streaming" when every item is still pending', async () => { + const query = gql` + query { + person { + id + friends { + id + ... @defer { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }, + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { __typename: "Person", name: "Leia" }, + id: "0", + }, + { + data: { __typename: "Person", name: "Han" }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "streaming" when one list item\'s `@defer` has arrived and another item is still pending', async () => { + const query = gql` + query { + person { + id + friends { + id + ... @defer { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }, + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { __typename: "Person", name: "Leia" }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { __typename: "Person", name: "Han" }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports overlapping deferred and non-deferred list fields as "streaming" when only defer-only item fields are missing', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + person { + id + friends { + id + name + } + ... on Person @defer { + friends { + id + name + email + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }, + pending: [{ id: "0", path: ["person"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "leia@example.com", + }, + id: "0", + subPath: ["friends", 0], + }, + { + data: { + __typename: "Person", + email: "han@example.com", + }, + id: "0", + subPath: ["friends", 1], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + name: "Leia", + email: "leia@example.com", + }, + { + __typename: "Person", + id: "3", + name: "Han", + email: "han@example.com", + }, + ], + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "partial" when one list item has a started `@defer` that is still incomplete while other items are only clean defer gaps', async () => { + // Suppress expected missing field warning when writing partial list items. + using _ = spyOnConsole("error"); + + const query = gql` + query { + person { + id + friends { + id + ... @defer { + email + phone + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "cached-leia@example.com", + }, + { + __typename: "Person", + id: "3", + }, + ], + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "cached-leia@example.com", + }, + { + __typename: "Person", + id: "3", + }, + ], + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }, + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "cached-leia@example.com", + }, + { + __typename: "Person", + id: "3", + }, + ], + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "leia@example.com", + phone: "555-0102", + }, + id: "0", + }, + { + data: { + __typename: "Person", + email: "han@example.com", + phone: "555-0103", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { + __typename: "Person", + id: "2", + email: "leia@example.com", + phone: "555-0102", + }, + { + __typename: "Person", + id: "3", + email: "han@example.com", + phone: "555-0103", + }, + ], + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports `@defer` nested under a field as "streaming" when only the nested deferred fields are missing', async () => { + const query = gql` + query { + greeting { + message + recipient { + name + ... on Person @defer { + email + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting", "recipient"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "partial" when a later `@defer` boundary is incomplete and an earlier sibling `@defer` is still fully pending', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... @defer { + signature + } + ... @defer { + recipient { + name + email + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // Seed only `recipient.name` so the second boundary is already mixed once + // the initial network chunk arrives (name present, email still missing). + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["greeting"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + signature: "From Apollo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + signature: "From Apollo", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "streaming" when two `@defer` fragments overlap and only the second fragment\'s exclusive fields are missing', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + ... @defer { + recipient { + name + email + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["greeting"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "1", + subPath: ["recipient"], + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "partial" when a deep defer-only field is present under an overlapped path while another deep defer-only field is still missing', async () => { + // Suppress expected missing field warning when writing partial nested values. + using _ = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + recipient { + address { + city + } + } + ... on Greeting @defer { + recipient { + address { + city + postalCode + line2 + } + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // `postalCode` is defer-only and already present; `line2` is still missing. + // Presence must be detected under the overlapped `recipient.address` path. + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "Cached City", + postalCode: "00000", + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "Cached City", + postalCode: "00000", + }, + }, + }, + }, dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, @@ -3484,38 +5030,32 @@ test('reports "partial" when one list item has a started `@defer` that is still enqueueInitialChunk({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { __typename: "Person", id: "2" }, - { __typename: "Person", id: "3" }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + address: { __typename: "Address", city: "New York" }, + }, }, }, - pending: [ - { id: "0", path: ["person", "friends", 0] }, - { id: "1", path: ["person", "friends", 1] }, - ], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { - __typename: "Person", - id: "2", - email: "cached-leia@example.com", - }, - { - __typename: "Person", - id: "3", + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + postalCode: "00000", }, - ], + }, }, }, dataState: "partial", @@ -3528,44 +5068,238 @@ test('reports "partial" when one list item has a started `@defer` that is still incremental: [ { data: { - __typename: "Person", - email: "leia@example.com", - phone: "555-0102", + __typename: "Address", + postalCode: "10001", + line2: "Apt 4", + }, + id: "0", + subPath: ["recipient", "address"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + address: { + __typename: "Address", + city: "New York", + postalCode: "10001", + line2: "Apt 4", + }, + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports overlapping fields gated by `@include` as "streaming" when the included non-deferred fields are present and only defer-only fields are missing', async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + + const query = gql` + query ($includeRecipient: Boolean!) { + greeting { + message + recipient @include(if: $includeRecipient) { + name + } + ... on Greeting @defer { + recipient { + name + email + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ + query, + variables: { includeRecipient: true }, + returnPartialData: true, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + // Classifier must honor `@include` via operation variables: `name` is + // non-deferred when included, so only missing `email` stays "streaming". + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Person", + email: "alice@example.com", + }, + id: "0", + subPath: ["recipient"], + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test('reports "streaming" when `@defer(if: false)` fields arrive in the initial payload while another `@defer` is still pending', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer(if: false) { + recipient { + name + } + } + ... on Greeting @defer { + signature + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + signature: "From Apollo", }, id: "0", }, - { - data: { - __typename: "Person", - email: "han@example.com", - phone: "555-0103", - }, - id: "1", - }, ], - completed: [{ id: "0" }, { id: "1" }], + completed: [{ id: "0" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - person: { - __typename: "Person", - id: "1", - friends: [ - { - __typename: "Person", - id: "2", - email: "leia@example.com", - phone: "555-0102", - }, - { - __typename: "Person", - id: "3", - email: "han@example.com", - phone: "555-0103", - }, - ], + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + signature: "From Apollo", }, }, dataState: "complete", @@ -3577,14 +5311,20 @@ test('reports "partial" when one list item has a started `@defer` that is still await expect(stream).not.toEmitAnything(); }); -test('reports `@defer` nested under a field as "streaming" when only the nested deferred fields are missing', async () => { +test("uses response aliases when matching overlapping non-deferred and deferred fields", async () => { + // Suppress expected missing field write warnings since defer context is reset. + using _ = spyOnConsole("error"); + const query = gql` query { greeting { message - recipient { + user: recipient { name - ... on Person @defer { + } + ... on Greeting @defer { + user: recipient { + name email } } @@ -3621,10 +5361,10 @@ test('reports `@defer` nested under a field as "streaming" when only the nested greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + user: { __typename: "Person", name: "Alice" }, }, }, - pending: [{ id: "0", path: ["greeting", "recipient"] }], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); @@ -3633,7 +5373,7 @@ test('reports `@defer` nested under a field as "streaming" when only the nested greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + user: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -3650,6 +5390,7 @@ test('reports `@defer` nested under a field as "streaming" when only the nested email: "alice@example.com", }, id: "0", + subPath: ["user"], }, ], completed: [{ id: "0" }], @@ -3661,7 +5402,7 @@ test('reports `@defer` nested under a field as "streaming" when only the nested greeting: { __typename: "Greeting", message: "Hello world", - recipient: { + user: { __typename: "Person", name: "Alice", email: "alice@example.com", @@ -3677,21 +5418,17 @@ test('reports `@defer` nested under a field as "streaming" when only the nested await expect(stream).not.toEmitAnything(); }); -test('reports "partial" when a later `@defer` boundary is incomplete and an earlier sibling `@defer` is still fully pending', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('reports "streaming" for nested `@defer` when the outer fragment has arrived and the inner fragment is still pending', async () => { const query = gql` query { greeting { message - ... @defer { - signature - } - ... @defer { + ... on Greeting @defer { recipient { name - email + ... on Person @defer { + email + } } } } @@ -3700,38 +5437,23 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); - const cache = new InMemoryCache(); - - // Seed only `recipient.name` so the second boundary is already mixed once - // the initial network chunk arrives (name present, email still missing). - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); const client = new ApolloClient({ - cache, + cache: new InMemoryCache(), link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ + query, + returnPartialData: true, + }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -3739,41 +5461,36 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl enqueueInitialChunk({ data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [ - { id: "0", path: ["greeting"] }, - { id: "1", path: ["greeting"] }, - ], + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: { + data: markAsStreaming({ greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, partial: true, }); + // Outer defer arrives with `recipient.name`; inner email defer is still + // pending at a nested path. enqueueSubsequentChunk({ incremental: [ { data: { - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, - id: "1", + id: "0", }, ], - completed: [{ id: "1" }], + pending: [{ id: "1", path: ["greeting", "recipient"] }], + completed: [{ id: "0" }], hasNext: true, }); @@ -3782,11 +5499,7 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }), dataState: "streaming", @@ -3799,12 +5512,13 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl incremental: [ { data: { - signature: "From Apollo", + __typename: "Person", + email: "alice@example.com", }, - id: "0", + id: "1", }, ], - completed: [{ id: "0" }], + completed: [{ id: "1" }], hasNext: false, }); @@ -3818,7 +5532,6 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl name: "Alice", email: "alice@example.com", }, - signature: "From Apollo", }, }, dataState: "complete", @@ -3830,7 +5543,7 @@ test('reports "partial" when a later `@defer` boundary is incomplete and an earl await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when two `@defer` fragments overlap and only the second fragment\'s exclusive fields are missing', async () => { +test("does not treat `__typename`-only presence under a `@defer` as the fragment having started", async () => { // Suppress expected missing field write warnings since defer context is reset. using _ = spyOnConsole("error"); @@ -3838,12 +5551,7 @@ test('reports "streaming" when two `@defer` fragments overlap and only the secon query { greeting { message - ... @defer { - recipient { - name - } - } - ... @defer { + ... on Greeting @defer { recipient { name email @@ -3855,72 +5563,72 @@ test('reports "streaming" when two `@defer` fragments overlap and only the secon const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: gql` + query { + greeting { + message + recipient { + __typename + } + } + } + `, + data: { + greeting: { + __typename: "Greeting", + message: "Cached", + recipient: { __typename: "Person" }, + }, + }, + }); const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) + client.watchQuery({ query, returnPartialData: true }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", + data: { + greeting: { + __typename: "Greeting", + message: "Cached", + recipient: { __typename: "Person" }, + }, + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.loading, partial: true, }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [ - { id: "0", path: ["greeting"] }, - { id: "1", path: ["greeting"] }, - ], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - enqueueSubsequentChunk({ - incremental: [ - { - data: { - recipient: { __typename: "Person", name: "Alice" }, - }, - id: "0", - }, - ], - completed: [{ id: "0" }], + }, + pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { __typename: "Person" }, }, - }), - dataState: "streaming", + }, + dataState: "partial", loading: true, networkStatus: NetworkStatus.streaming, partial: true, @@ -3930,14 +5638,16 @@ test('reports "streaming" when two `@defer` fragments overlap and only the secon incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, - id: "1", - subPath: ["recipient"], + id: "0", }, ], - completed: [{ id: "1" }], + completed: [{ id: "0" }], hasNext: false, }); @@ -3962,26 +5672,14 @@ test('reports "streaming" when two `@defer` fragments overlap and only the secon await expect(stream).not.toEmitAnything(); }); -test('reports "partial" when a deep defer-only field is present under an overlapped path while another deep defer-only field is still missing', async () => { - // Suppress expected missing field warning when writing partial nested values. - using _ = spyOnConsole("error"); - +test('reports residual deferred cached data as "complete" while streaming with a "cache-first" fetch policy and returnPartialData', async () => { const query = gql` query { greeting { - message - recipient { - address { - city - } - } - ... on Greeting @defer { - recipient { - address { - city - postalCode - line2 - } + message + ... on Greeting @defer { + recipient { + name } } } @@ -3992,24 +5690,20 @@ test('reports "partial" when a deep defer-only field is present under an overlap mockDeferStreamGraphQL17Alpha9(); const cache = new InMemoryCache(); - // `postalCode` is defer-only and already present; `line2` is still missing. - // Presence must be detected under the overlapped `recipient.address` path. - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { - __typename: "Person", - address: { - __typename: "Address", - city: "Cached City", - postalCode: "00000", - }, + // We are intentionally writing partial data to the cache. Suppress 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, @@ -4020,6 +5714,7 @@ test('reports "partial" when a deep defer-only field is present under an overlap const stream = new ObservableStream( client.watchQuery({ query, + fetchPolicy: "cache-first", returnPartialData: true, }) ); @@ -4028,14 +5723,7 @@ test('reports "partial" when a deep defer-only field is present under an overlap data: { greeting: { __typename: "Greeting", - recipient: { - __typename: "Person", - address: { - __typename: "Address", - city: "Cached City", - postalCode: "00000", - }, - }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, }, dataState: "partial", @@ -4045,51 +5733,35 @@ test('reports "partial" when a deep defer-only field is present under an overlap }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - address: { __typename: "Address", city: "New York" }, - }, - }, - }, + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); + // Selection set is fully satisfied from network message + residual cached + // recipient, so dataState is complete even though the stream is still open. await expect(stream).toEmitTypedValue({ data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - address: { - __typename: "Address", - city: "New York", - postalCode: "00000", - }, - }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, }, - dataState: "partial", + dataState: "complete", loading: true, networkStatus: NetworkStatus.streaming, - partial: true, + partial: false, }); enqueueSubsequentChunk({ incremental: [ { data: { - __typename: "Address", - postalCode: "10001", - line2: "Apt 4", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, }, id: "0", - subPath: ["recipient", "address"], }, ], completed: [{ id: "0" }], @@ -4101,15 +5773,7 @@ test('reports "partial" when a deep defer-only field is present under an overlap greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - address: { - __typename: "Address", - city: "New York", - postalCode: "10001", - line2: "Apt 4", - }, - }, + recipient: { __typename: "Person", name: "Alice" }, }, }, dataState: "complete", @@ -4121,21 +5785,14 @@ test('reports "partial" when a deep defer-only field is present under an overlap await expect(stream).not.toEmitAnything(); }); -test('reports overlapping fields gated by `@include` as "streaming" when the included non-deferred fields are present and only defer-only fields are missing', async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('reports complete cached deferred data as "complete" while streaming with a "cache-and-network" fetch policy', async () => { const query = gql` - query ($includeRecipient: Boolean!) { + query { greeting { message - recipient @include(if: $includeRecipient) { - name - } ... on Greeting @defer { recipient { name - email } } } @@ -4151,59 +5808,65 @@ test('reports overlapping fields gated by `@include` as "streaming" when the inc incrementalHandler: new GraphQL17Alpha9Handler(), }); + client.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const stream = new ObservableStream( - client.watchQuery({ - query, - variables: { includeRecipient: true }, - returnPartialData: true, - }) + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) ); await expect(stream).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - - enqueueInitialChunk({ data: { greeting: { + message: "Hello cached", __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); - // Classifier must honor `@include` via operation variables: `name` is - // non-deferred when included, so only missing `email` stays "streaming". + // Residual cached recipient keeps the selection set complete while the + // deferred network chunk is still pending. await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ + data: { greeting: { __typename: "Greeting", message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + recipient: { __typename: "Person", name: "Cached Alice" }, }, - }), - dataState: "streaming", + }, + dataState: "complete", loading: true, networkStatus: NetworkStatus.streaming, - partial: true, + partial: false, }); enqueueSubsequentChunk({ incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", }, id: "0", - subPath: ["recipient"], }, ], completed: [{ id: "0" }], @@ -4215,11 +5878,7 @@ test('reports overlapping fields gated by `@include` as "streaming" when the inc greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }, dataState: "complete", @@ -4231,34 +5890,43 @@ test('reports overlapping fields gated by `@include` as "streaming" when the inc await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" when `@defer(if: false)` fields arrive in the initial payload while another `@defer` is still pending', async () => { +test('does not return complete cached deferred data while streaming with a "network-only" fetch policy', async () => { const query = gql` query { greeting { message - ... on Greeting @defer(if: false) { + ... on Greeting @defer { recipient { name } } - ... on Greeting @defer { - signature - } } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ query, fetchPolicy: "network-only" }) ); await expect(stream).toEmitTypedValue({ @@ -4270,24 +5938,14 @@ test('reports "streaming" when `@defer(if: false)` fields arrive in the initial }); enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, + greeting: { __typename: "Greeting", message: "Hello world" }, }), dataState: "streaming", loading: true, @@ -4300,7 +5958,7 @@ test('reports "streaming" when `@defer(if: false)` fields arrive in the initial { data: { __typename: "Greeting", - signature: "From Apollo", + recipient: { __typename: "Person", name: "Alice" }, }, id: "0", }, @@ -4315,7 +5973,6 @@ test('reports "streaming" when `@defer(if: false)` fields arrive in the initial __typename: "Greeting", message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, - signature: "From Apollo", }, }, dataState: "complete", @@ -4327,19 +5984,13 @@ test('reports "streaming" when `@defer(if: false)` fields arrive in the initial await expect(stream).not.toEmitAnything(); }); -test("uses response aliases when matching overlapping non-deferred and deferred fields", async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('does not return a partial cached defer boundary while streaming with a "network-only" fetch policy', async () => { const query = gql` query { greeting { message - user: recipient { - name - } ... on Greeting @defer { - user: recipient { + recipient { name email } @@ -4350,18 +6001,30 @@ test("uses response aliases when matching overlapping non-deferred and deferred const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) + client.watchQuery({ query, fetchPolicy: "network-only" }) ); await expect(stream).toEmitTypedValue({ @@ -4372,41 +6035,37 @@ test("uses response aliases when matching overlapping non-deferred and deferred partial: true, }); - enqueueInitialChunk({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - user: { __typename: "Person", name: "Alice" }, - }, - }, - pending: [{ id: "0", path: ["greeting"] }], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - user: { __typename: "Person", name: "Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); + { + using _consoleSpy = spyOnConsole("error"); + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { __typename: "Greeting", message: "Hello world" }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + } enqueueSubsequentChunk({ incremental: [ { data: { - __typename: "Person", - email: "alice@example.com", + __typename: "Greeting", + recipient: { + __typename: "Person", + name: "Alice", + email: "alice@example.com", + }, }, id: "0", - subPath: ["user"], }, ], completed: [{ id: "0" }], @@ -4418,7 +6077,7 @@ test("uses response aliases when matching overlapping non-deferred and deferred greeting: { __typename: "Greeting", message: "Hello world", - user: { + recipient: { __typename: "Person", name: "Alice", email: "alice@example.com", @@ -4434,7 +6093,7 @@ test("uses response aliases when matching overlapping non-deferred and deferred await expect(stream).not.toEmitAnything(); }); -test('reports "streaming" for nested `@defer` when the outer fragment has arrived and the inner fragment is still pending', async () => { +test('does not return cached non-deferred data through a clean defer boundary while streaming with a "network-only" fetch policy', async () => { const query = gql` query { greeting { @@ -4442,9 +6101,6 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive ... on Greeting @defer { recipient { name - ... on Person @defer { - email - } } } } @@ -4453,18 +6109,29 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); const stream = new ObservableStream( - client.watchQuery({ - query, - returnPartialData: true, - }) + client.watchQuery({ query, fetchPolicy: "network-only" }) ); await expect(stream).toEmitTypedValue({ @@ -4476,17 +6143,14 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive }); enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, pending: [{ id: "0", path: ["greeting"] }], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, + greeting: { __typename: "Greeting", message: "Hello world" }, }), dataState: "streaming", loading: true, @@ -4494,47 +6158,17 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive partial: true, }); - // Outer defer arrives with `recipient.name`; inner email defer is still - // pending at a nested path. enqueueSubsequentChunk({ incremental: [ { data: { + __typename: "Greeting", recipient: { __typename: "Person", name: "Alice" }, }, id: "0", }, ], - pending: [{ id: "1", path: ["greeting", "recipient"] }], completed: [{ id: "0" }], - hasNext: true, - }); - - await expect(stream).toEmitTypedValue({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - enqueueSubsequentChunk({ - incremental: [ - { - data: { - __typename: "Person", - email: "alice@example.com", - }, - id: "1", - }, - ], - completed: [{ id: "1" }], hasNext: false, }); @@ -4543,11 +6177,7 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive greeting: { __typename: "Greeting", message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + recipient: { __typename: "Person", name: "Alice" }, }, }, dataState: "complete", @@ -4559,18 +6189,15 @@ test('reports "streaming" for nested `@defer` when the outer fragment has arrive await expect(stream).not.toEmitAnything(); }); -test("does not treat `__typename`-only presence under a `@defer` as the fragment having started", async () => { - // Suppress expected missing field write warnings since defer context is reset. - using _ = spyOnConsole("error"); - +test('does not return cached defer boundaries for list items while streaming with a "network-only" fetch policy', async () => { const query = gql` query { - greeting { - message - ... on Greeting @defer { - recipient { + person { + id + friends { + id + ... @defer { name - email } } } @@ -4582,21 +6209,15 @@ test("does not treat `__typename`-only presence under a `@defer` as the fragment const cache = new InMemoryCache(); cache.writeQuery({ - query: gql` - query { - greeting { - message - recipient { - __typename - } - } - } - `, + query, data: { - greeting: { - __typename: "Greeting", - message: "Cached", - recipient: { __typename: "Person" }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Cached Leia" }, + { __typename: "Person", id: "3", name: "Cached Han" }, + ], }, }, }); @@ -4608,18 +6229,12 @@ test("does not treat `__typename`-only presence under a `@defer` as the fragment }); const stream = new ObservableStream( - client.watchQuery({ query, returnPartialData: true }) + client.watchQuery({ query, fetchPolicy: "network-only" }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Cached", - recipient: { __typename: "Person" }, - }, - }, - dataState: "partial", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, partial: true, @@ -4627,22 +6242,31 @@ test("does not treat `__typename`-only presence under a `@defer` as the fragment enqueueInitialChunk({ data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person" }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], }, }, - pending: [{ id: "0", path: ["greeting"] }], + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], hasNext: true, }); await expect(stream).toEmitTypedValue({ data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person" }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], }, }), dataState: "streaming", @@ -4653,31 +6277,22 @@ test("does not treat `__typename`-only presence under a `@defer` as the fragment enqueueSubsequentChunk({ incremental: [ - { - data: { - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, - }, - id: "0", - }, + { data: { __typename: "Person", name: "Leia" }, id: "0" }, + { data: { __typename: "Person", name: "Han" }, id: "1" }, ], - completed: [{ id: "0" }], + completed: [{ id: "0" }, { id: "1" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - email: "alice@example.com", - }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], }, }, dataState: "complete", @@ -4689,13 +6304,14 @@ test("does not treat `__typename`-only presence under a `@defer` as the fragment await expect(stream).not.toEmitAnything(); }); -test('reports residual deferred cached data as "complete" while streaming with a "cache-first" fetch policy and returnPartialData', async () => { +test('prunes a list item\'s cached defer boundary while a sibling item has already streamed in with a "network-only" fetch policy', async () => { const query = gql` query { - greeting { - message - ... on Greeting @defer { - recipient { + person { + id + friends { + id + ... @defer { name } } @@ -4707,20 +6323,19 @@ test('reports residual deferred cached data as "complete" while streaming with a mockDeferStreamGraphQL17Alpha9(); const cache = new InMemoryCache(); - // We are intentionally writing partial data to the cache. Suppress 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" }, - }, + cache.writeQuery({ + query, + data: { + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Cached Leia" }, + { __typename: "Person", id: "3", name: "Cached Han" }, + ], }, - }); - } + }, + }); const client = new ApolloClient({ cache, @@ -4729,68 +6344,90 @@ test('reports residual deferred cached data as "complete" while streaming with a }); const stream = new ObservableStream( - client.watchQuery({ - query, - fetchPolicy: "cache-first", - returnPartialData: true, - }) + client.watchQuery({ query, fetchPolicy: "network-only" }) ); await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], + }, + }, + pending: [ + { id: "0", path: ["person", "friends", 0] }, + { id: "1", path: ["person", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2" }, + { __typename: "Person", id: "3" }, + ], }, - }, - dataState: "partial", + }), + dataState: "streaming", loading: true, - networkStatus: NetworkStatus.loading, + networkStatus: NetworkStatus.streaming, partial: true, }); - enqueueInitialChunk({ - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - pending: [{ id: "0", path: ["greeting"] }], + enqueueSubsequentChunk({ + incremental: [{ data: { __typename: "Person", name: "Leia" }, id: "0" }], hasNext: true, + completed: [{ id: "0" }], }); - // Selection set is fully satisfied from network message + residual cached - // recipient, so dataState is complete even though the stream is still open. await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, + data: markAsStreaming({ + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3" }, + ], }, - }, - dataState: "complete", + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, - partial: false, + partial: true, }); enqueueSubsequentChunk({ - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - id: "0", - }, - ], - completed: [{ id: "0" }], + incremental: [{ data: { __typename: "Person", name: "Han" }, id: "1" }], + completed: [{ id: "1" }], hasNext: false, }); await expect(stream).toEmitTypedValue({ data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, + person: { + __typename: "Person", + id: "1", + friends: [ + { __typename: "Person", id: "2", name: "Leia" }, + { __typename: "Person", id: "3", name: "Han" }, + ], }, }, dataState: "complete", @@ -4802,7 +6439,7 @@ test('reports residual deferred cached data as "complete" while streaming with a await expect(stream).not.toEmitAnything(); }); -test('reports complete cached deferred data as "complete" while streaming with a "cache-and-network" fetch policy', async () => { +test('does not leak complete or partial cached defer boundaries while streaming with a "network-only" fetch policy and returnPartialData: true', async () => { const query = gql` query { greeting { @@ -4813,80 +6450,99 @@ test('reports complete cached deferred data as "complete" while streaming with a } } } + hero { + id + ... @defer { + name + email + } + } } `; const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + hero: { __typename: "Hero", id: "1", name: "Cached Luke" }, + }, + }); + } const client = new ApolloClient({ - cache: new InMemoryCache(), + cache, link: httpLink, incrementalHandler: new GraphQL17Alpha9Handler(), }); - client.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - const stream = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + client.watchQuery({ + query, + fetchPolicy: "network-only", + returnPartialData: true, + }) ); await expect(stream).toEmitTypedValue({ - data: { - greeting: { - message: "Hello cached", - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", + data: undefined, + dataState: "empty", loading: true, networkStatus: NetworkStatus.loading, - partial: false, + partial: true, }); enqueueInitialChunk({ - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - pending: [{ id: "0", path: ["greeting"] }], + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + hero: { __typename: "Hero", id: "1" }, + }, + pending: [ + { id: "0", path: ["greeting"] }, + { id: "1", path: ["hero"] }, + ], hasNext: true, }); - // Residual cached recipient keeps the selection set complete while the - // deferred network chunk is still pending. await expect(stream).toEmitTypedValue({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", + data: markAsStreaming({ + greeting: { __typename: "Greeting", message: "Hello world" }, + hero: { __typename: "Hero", id: "1" }, + }), + dataState: "streaming", loading: true, networkStatus: NetworkStatus.streaming, - partial: false, + partial: true, }); enqueueSubsequentChunk({ incremental: [ { data: { - recipient: { name: "Alice", __typename: "Person" }, __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, }, id: "0", }, + { + data: { + __typename: "Hero", + name: "Luke", + email: "luke@example.com", + }, + id: "1", + }, ], - completed: [{ id: "0" }], + completed: [{ id: "0" }, { id: "1" }], hasNext: false, }); @@ -4897,6 +6553,12 @@ test('reports complete cached deferred data as "complete" while streaming with a message: "Hello world", recipient: { __typename: "Person", name: "Alice" }, }, + hero: { + __typename: "Hero", + id: "1", + name: "Luke", + email: "luke@example.com", + }, }, dataState: "complete", loading: false, @@ -5400,6 +7062,113 @@ test("treats a deferred field delivered as null as fulfilled rather than still m await expect(stream).not.toEmitAnything(); }); +test('applies field read functions to in-flight results while pruning cached deferred data with a "network-only" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache({ + typePolicies: { + Greeting: { + fields: { + message: { read: uppercaseRead }, + }, + }, + Person: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Cached hello", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + greeting: { __typename: "Greeting", message: "HELLO WORLD" }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + greeting: { + __typename: "Greeting", + message: "HELLO WORLD", + recipient: { __typename: "Person", name: "ALICE" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + test("applies field read functions to non-deferred fields while streaming deferred results", async () => { const query = gql` query { diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts index 327c7569cbe..f142eff2b8c 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -896,6 +896,100 @@ test("handles @defer inside @stream", async () => { await expect(observableStream).not.toEmitAnything(); }); +test('reports "complete" once a sibling @defer boundary resolves while a @stream is still streaming with a "no-cache" fetch policy', async () => { + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + ... @defer { + someScalar + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "no-cache" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { friendList: [{ __typename: "Friend", id: "1", name: "Luke" }] }, + pending: [ + { id: "0", path: [] }, + { id: "1", path: ["friendList"] }, + ], + hasNext: true, + } as any); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [{ id: "0", data: { someScalar: "resolved" } }], + completed: [{ id: "0" }], + hasNext: true, + } as any); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + someScalar: "resolved", + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + enqueueSubsequentChunk({ + incremental: [ + { id: "1", items: [{ __typename: "Friend", id: "2", name: "Han" }] }, + ], + completed: [{ id: "1" }], + hasNext: false, + } as any); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + someScalar: "resolved", + }, + 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: { @@ -2544,8 +2638,8 @@ test("reports data as partial if a cache merge function returns partial data", a await expect(stream).not.toEmitAnything(); }); -test("applies field read functions to streamed list items on intermediate and final results", async () => { - const { subject, stream: iterableStream } = asyncIterableSubject(); +test("does not surface incomplete cached stream list items with returnPartialData: false", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); const query = gql` query { @@ -2556,17 +2650,24 @@ test("applies field read functions to streamed list items on intermediate and fi } `; - const client = new ApolloClient({ - cache: new InMemoryCache({ - typePolicies: { - Friend: { - fields: { - name: { read: uppercaseRead }, - }, - }, + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), }, - }), - link: createLink({ friendList: async () => iterableStream }), + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -2580,11 +2681,11 @@ test("applies field read functions to streamed list items on intermediate and fi partial: true, }); - subject.next({ name: "Luke", id: 1 }); + subject.next(friends[0]); await expect(stream).toEmitTypedValue({ data: { - friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], }, dataState: "complete", loading: true, @@ -2592,13 +2693,13 @@ test("applies field read functions to streamed list items on intermediate and fi partial: false, }); - subject.next({ name: "Han", id: 2 }); + subject.next(friends[1]); await expect(stream).toEmitTypedValue({ data: { friendList: [ - { __typename: "Friend", id: "1", name: "LUKE" }, - { __typename: "Friend", id: "2", name: "HAN" }, + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, ], }, dataState: "complete", @@ -2607,14 +2708,103 @@ test("applies field read functions to streamed list items on intermediate and fi partial: false, }); - subject.next({ name: "Leia", id: 3 }); + subject.next(friends[2]); + subject.complete(); await expect(stream).toEmitTypedValue({ data: { friendList: [ - { __typename: "Friend", id: "1", name: "LUKE" }, - { __typename: "Friend", id: "2", name: "HAN" }, - { __typename: "Friend", id: "3", name: "LEIA" }, + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + 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(); +}); + +test("emits stream items when network chunks are deeply equal to partial cached data", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, ], }, dataState: "complete", @@ -2625,6 +2815,1294 @@ test("applies field read functions to streamed list items on intermediate and fi subject.complete(); + await expect(stream).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(stream).not.toEmitAnything(); +}); + +test("hides a partial cached stream array until the network supplies stream items with returnPartialData: false", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Cached Luke", + }, + { + __typename: "Friend", + id: "2", + name: "Cached Han", + }, + { + __typename: "Friend", + id: "3", + }, + { + __typename: "Friend", + id: "4", + name: "Cached Chewbacca", + }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[2]); + subject.complete(); + + 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: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + 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(); +}); + +test("fetches a partial stream array with a cache-first fetch policy", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-first" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("does not fetch a complete stream array with a cache-first fetch policy", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + ], + }, + }); + + const client = new ApolloClient({ + cache, + link: createLink({ + friendList: () => friends, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-first" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns empty for partial stream array for cache-only fetch policy", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: false, + networkStatus: NetworkStatus.ready, + partial: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns stream items with complete stream array for cache-only fetch policy", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + 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(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-only" }) + ); + + 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: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns only streamed items with applied field read functions using a network-only fetch policy", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Friend: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "LUKE" }, + { __typename: "Friend", id: "2", name: "HAN" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + await expect(stream).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(stream).not.toEmitAnything(); +}); + +test("only returns streamed items with field read functions with complete array and a network-only fetch policy", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Friend: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }); + + { + using _ = spyOnConsole("error"); + 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: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "LUKE" }, + { __typename: "Friend", id: "2", name: "HAN" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[2]); + + 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: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + 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(); +}); + +test("only returns streamed items from a partial array with a cache-and-network fetch policy", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Friend: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("keeps all cached stream items including incomplete ones until the last chunk with returnPartialData: true", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream( + client.watchQuery({ query, returnPartialData: true }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }), + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }), + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + subject.next(friends[2]); + + 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" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }), + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + 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(); +}); + +test("does not surface incomplete cached fields on a streamed item after the network fills that item with returnPartialData: false", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + }, + { + __typename: "Friend", + id: "2", + name: "Cached Han", + }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + subject.complete(); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + await expect(stream).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(stream).not.toEmitAnything(); +}); + +test("does not surface incomplete cached fields inside a `@defer` boundary on streamed list items with returnPartialData: false", async () => { + const mock = mockDeferStreamGraphQL17Alpha9(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + ... on Friend @defer { + email + phone + } + } + } + `; + + const cache = new InMemoryCache(); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Cached Luke", + email: "cached-luke@example.com", + }, + { + __typename: "Friend", + id: "2", + name: "Cached Han", + }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: mock.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + mock.enqueueInitialChunk({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + ], + }, + pending: [ + { id: "0", path: ["friendList", 0] }, + { id: "1", path: ["friendList"] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + }, + { + __typename: "Friend", + id: "2", + name: "Cached Han", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + mock.enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Friend", + email: "luke@example.com", + phone: "555-0101", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0101", + }, + { + __typename: "Friend", + id: "2", + name: "Cached Han", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + mock.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Friend", + id: "2", + name: "Han", + }, + ] as any, + id: "1", + }, + ], + pending: [{ id: "2", path: ["friendList", 1] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0101", + }, + { + __typename: "Friend", + id: "2", + name: "Han", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + mock.enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Friend", + email: "han@example.com", + phone: "555-0102", + }, + id: "2", + }, + ], + completed: [{ id: "1" }, { id: "2" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Luke", + email: "luke@example.com", + phone: "555-0101", + }, + { + __typename: "Friend", + id: "2", + name: "Han", + email: "han@example.com", + phone: "555-0102", + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("applies field read functions to streamed list items on intermediate and final results", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Friend: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }), + link: createLink({ friendList: async () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next({ name: "Luke", id: 1 }); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next({ name: "Han", id: 2 }); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "LUKE" }, + { __typename: "Friend", id: "2", name: "HAN" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next({ name: "Leia", id: 3 }); + + 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: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.complete(); + + 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(); +}); + +test("applies field read functions to streamed items while truncating length with an incomplete stream item with returnPartialData: false", async () => { + const { subject, stream: iterableStream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Friend: { + fields: { + name: { read: uppercaseRead }, + }, + }, + }, + }); + + { + using _ = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3" }, + { __typename: "Friend", id: "4", name: "Cached Chewbacca" }, + ], + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => iterableStream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + subject.next(friends[0]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "LUKE" }], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[1]); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "LUKE" }, + { __typename: "Friend", id: "2", name: "HAN" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + subject.next(friends[2]); + subject.complete(); + + 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: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + await expect(stream).toEmitTypedValue({ data: { friendList: [ diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 6af48921152..ef02cbb493d 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -1,8 +1,4 @@ -import type { - DocumentNode, - FormattedExecutionResult, - GraphQLFormattedError, -} from "graphql"; +import type { FormattedExecutionResult, GraphQLFormattedError } from "graphql"; import type { ApolloLink } from "@apollo/client/link"; import type { DeepPartial, HKT } from "@apollo/client/utilities"; @@ -214,9 +210,9 @@ export class Defer20220824Handler return request; } - startRequest>(_: { - query: DocumentNode; - }) { + startRequest>( + _: Incremental.StartRequestOptions + ) { return new DeferRequest(); } } diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index e6b7dcf3975..078d77322e4 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -1,18 +1,11 @@ -import { Trie } from "@wry/trie"; -import type { - DocumentNode, - FormattedExecutionResult, - GraphQLFormattedError, -} from "graphql"; +import type { FormattedExecutionResult, GraphQLFormattedError } from "graphql"; import type { ApolloLink } from "@apollo/client/link"; import type { DeepPartial, HKT } from "@apollo/client/utilities"; -import type { - ExtensionsWithStreamInfo, - StreamInfoTrie, -} from "@apollo/client/utilities/internal"; +import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal"; import { DeepMerger, + makeStreamInfoTrie, streamInfoSymbol, } from "@apollo/client/utilities/internal"; import { @@ -93,10 +86,8 @@ class IncrementalRequest private data: any = {}; private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; - private pending = new Map(); - private streamInfo: StreamInfoTrie = new Trie(false, () => ({ - current: { isFirstChunk: true, isLastChunk: false }, - })); + private pendingMap = new Map(); + private streamInfo = makeStreamInfoTrie(); // `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 @@ -106,6 +97,14 @@ class IncrementalRequest // of future stream items from already merged stream items. private streamPositions: Record = {}; + get pending() { + return Array.from(this.pendingMap.values()); + } + + getPendingType(id: string): "defer" | "stream" { + return id in this.streamPositions ? "stream" : "defer"; + } + handle( cacheData: TData | DeepPartial | null | undefined = this.data, chunk: GraphQL17Alpha9Handler.Chunk @@ -115,7 +114,7 @@ class IncrementalRequest if (chunk.pending) { for (const pending of chunk.pending) { - this.pending.set(pending.id, pending); + this.pendingMap.set(pending.id, pending); if ("data" in chunk) { const dataAtPath = pending.path.reduce( @@ -125,10 +124,12 @@ class IncrementalRequest if (Array.isArray(dataAtPath)) { this.streamPositions[pending.id] = dataAtPath.length; - this.streamInfo.lookupArray(pending.path as any[]).current = { + const entry = this.streamInfo.lookupArray(pending.path as any[]); + entry.current = { isFirstChunk: true, isLastChunk: false, }; + entry.state.streamPosition = dataAtPath.length; } } } @@ -136,7 +137,7 @@ class IncrementalRequest if (hasIncrementalChunks(chunk)) { for (const incremental of chunk.incremental) { - const pending = this.pending.get(incremental.id); + const pending = this.pendingMap.get(incremental.id); invariant( pending, @@ -158,10 +159,13 @@ class IncrementalRequest } this.streamPositions[pending.id] += items.length; - this.streamInfo.lookupArray(path).current = { + const entry = this.streamInfo.lookupArray(path); + entry.current = { isFirstChunk: false, isLastChunk: false, }; + entry.state.streamPosition = this.streamPositions[pending.id]; + data = parent; } else { data = incremental.data; @@ -170,7 +174,7 @@ class IncrementalRequest // that we can update streamPositions with the initial length of the // array to ensure future streamed items are inserted at the right // starting index. - this.pending.forEach((pendingItem) => { + this.pendingMap.forEach((pendingItem) => { 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 @@ -205,7 +209,7 @@ class IncrementalRequest if ("completed" in chunk && chunk.completed) { for (const completed of chunk.completed) { - const { path } = this.pending.get(completed.id)!; + const { path } = this.pendingMap.get(completed.id)!; const streamPosition = this.streamPositions[completed.id]; // Truncate any stream arrays in case the chunk only contains `hasNext` @@ -226,8 +230,9 @@ class IncrementalRequest isFirstChunk: false, isLastChunk: true, }; + details.state.streamPosition = streamPosition; } - this.pending.delete(completed.id); + this.pendingMap.delete(completed.id); if (completed.errors) { this.errors.push(...completed.errors); @@ -346,7 +351,7 @@ export class GraphQL17Alpha9Handler } /** @internal */ - startRequest(_: { query: DocumentNode }) { + startRequest(_: Incremental.StartRequestOptions) { return new IncrementalRequest(); } } diff --git a/src/incremental/types.ts b/src/incremental/types.ts index 642b1869ac9..e787ee70529 100644 --- a/src/incremental/types.ts +++ b/src/incremental/types.ts @@ -10,6 +10,12 @@ import type { DeepPartial } from "@apollo/client/utilities"; export declare namespace Incremental { export type Path = ReadonlyArray; + export interface PendingResult { + id: string; + path: Incremental.Path; + label?: string; + } + /** @internal */ export interface Handler< Chunk extends Record = Record, @@ -19,9 +25,14 @@ export declare namespace Incremental { extractErrors: ( result: ApolloLink.Result ) => readonly GraphQLFormattedError[] | undefined | void; - startRequest: >(request: { - query: DocumentNode; - }) => IncrementalRequest; + startRequest: >( + request: Incremental.StartRequestOptions + ) => IncrementalRequest; + } + + /** @internal */ + export interface StartRequestOptions { + query: DocumentNode; } export interface IncrementalRequest< @@ -29,6 +40,8 @@ export declare namespace Incremental { TData, > { hasNext: boolean; + pending?: Array; + getPendingType?: (id: string) => "defer" | "stream"; handle: ( cacheData: TData | DeepPartial | undefined | null, chunk: Chunk diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index 9326a315caf..2a27586455e 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -21,6 +21,7 @@ import type { Observer } from "rxjs"; import { Observable } from "rxjs"; import type { + ApolloCache, DocumentNode, OperationVariables, Reference, @@ -2642,7 +2643,7 @@ describe("works with skip/non-include of all fields, even if the cache doesn't a // but we want to simulate a 3rd party cache that doesn't add `__typename` // without building an entire mock cache in this test. Instead we // override the diff method to strip it out again - cache.diff = (options) => { + (cache as ApolloCache).diff = (options) => { const diff = (InMemoryCache.prototype.diff).call(cache, options); if (diff.result) { const { __typename: _, ...rest } = diff.result; diff --git a/src/utilities/caching/__tests__/getMemoryInternals.ts b/src/utilities/caching/__tests__/getMemoryInternals.ts index cd21e7b8f66..680bc736502 100644 --- a/src/utilities/caching/__tests__/getMemoryInternals.ts +++ b/src/utilities/caching/__tests__/getMemoryInternals.ts @@ -24,6 +24,8 @@ function sha256(data: string) { const defaultCacheSizesAsObject = { canonicalStringify: defaultCacheSizes["canonicalStringify"], checkDocument: defaultCacheSizes["checkDocument"], + isDeferredFragment: defaultCacheSizes["isDeferredFragment"], + isStreamField: defaultCacheSizes["isStreamField"], print: defaultCacheSizes["print"], "documentTransform.cache": defaultCacheSizes["documentTransform.cache"], "queryManager.getDocumentInfo": @@ -44,6 +46,10 @@ const defaultCacheSizesAsObject = { defaultCacheSizes["inMemoryCache.executeSelectionSet"], "inMemoryCache.executeSubSelectedArray": defaultCacheSizes["inMemoryCache.executeSubSelectedArray"], + "inMemoryCache.prunePartialBoundaries": + defaultCacheSizes["inMemoryCache.prunePartialBoundaries"], + "inMemoryCache.prunePartialStreamArray": + defaultCacheSizes["inMemoryCache.prunePartialStreamArray"], }; it("returns information about cache usage (empty caches)", () => { @@ -64,7 +70,7 @@ it("returns information about cache usage (empty caches)", () => { .concat(new RemoveTypenameFromVariablesLink()) .concat(ApolloLink.empty()), }); - expect(client.getMemoryInternals?.()).toEqual({ + expect(client.getMemoryInternals?.()).toStrictEqualTyped({ limits: defaultCacheSizesAsObject, sizes: { canonicalStringify: 0, @@ -97,6 +103,8 @@ it("returns information about cache usage (empty caches)", () => { executeSelectionSet: 0, executeSubSelectedArray: 0, maybeBroadcastWatch: 0, + prunePartialBoundaries: 0, + prunePartialStreamArray: 0, }, links: [ { @@ -142,7 +150,7 @@ it("returns information about cache usage (some query triggered)", () => { `, }) .catch(() => {}); - expect(client.getMemoryInternals?.()).toStrictEqual({ + expect(client.getMemoryInternals?.()).toStrictEqualTyped({ limits: defaultCacheSizesAsObject, sizes: { canonicalStringify: 0, @@ -175,6 +183,8 @@ it("returns information about cache usage (some query triggered)", () => { executeSelectionSet: 1, executeSubSelectedArray: 0, maybeBroadcastWatch: 0, + prunePartialBoundaries: 0, + prunePartialStreamArray: 0, }, links: [ { diff --git a/src/utilities/caching/sizes.ts b/src/utilities/caching/sizes.ts index 5b872d303a8..5f987612136 100644 --- a/src/utilities/caching/sizes.ts +++ b/src/utilities/caching/sizes.ts @@ -244,11 +244,47 @@ export interface CacheSizes { * recommended to set this to a high value. */ "inMemoryCache.executeSubSelectedArray": number; + /** + * Cache size for the `prunePartialBoundaries` method on [`StoreReader`](https://github.com/apollographql/apollo-client/blob/main/src/cache/inmemory/readFromStore.ts). + * + * @defaultValue + * Defaults to `20000`. + * + * @remarks + * Every object that is read from the cache for `@defer` and `@stream` + * queries will be cached here, so it is recommended to set this to a + * relatively high value. + */ + "inMemoryCache.prunePartialBoundaries": number; + /** + * Cache size for the `prunePartialStreamArray` method on [`StoreReader`](https://github.com/apollographql/apollo-client/blob/main/src/cache/inmemory/readFromStore.ts). + * + * @defaultValue + * Defaults to `20000`. + * + * @remarks + * Every array that is read from the cache for `@defer` and `@stream` + * queries will be cached here, so it is recommended to set this to a + * relatively high value. + */ + "inMemoryCache.prunePartialStreamArray": number; /** * Used by the internal `checkDocument` that traverses GraphQL documents and throws an error if the document is invalid. * if they are not valid. */ checkDocument: number; + + /** + * Used by the internal `isDeferredFragment` utility that determines whether a + * fragment selection node is a deferred fragment. + */ + isDeferredFragment: number; + + /** + * Used by the internal `isStreamField` utility that determines whether a + * field selection node is a streamed field. + */ + isStreamField: number; } const cacheSizeSymbol = Symbol.for("apollo.cacheSize"); @@ -278,6 +314,8 @@ export const cacheSizes: Partial = { ...global[cacheSizeSymbol] }; export const enum defaultCacheSizes { checkDocument = 2000, + isDeferredFragment = 2000, + isStreamField = 2000, canonicalStringify = 1000, print = 2000, "documentTransform.cache" = 2000, @@ -291,4 +329,6 @@ export const enum defaultCacheSizes { "inMemoryCache.maybeBroadcastWatch" = 5000, "inMemoryCache.executeSelectionSet" = 50000, "inMemoryCache.executeSubSelectedArray" = 10000, + "inMemoryCache.prunePartialBoundaries" = 20000, + "inMemoryCache.prunePartialStreamArray" = 20000, } diff --git a/src/utilities/internal/StreamArrayState.ts b/src/utilities/internal/StreamArrayState.ts new file mode 100644 index 00000000000..f82363dc8d1 --- /dev/null +++ b/src/utilities/internal/StreamArrayState.ts @@ -0,0 +1,30 @@ +import { dep } from "optimism"; + +import type { Incremental } from "@apollo/client/incremental"; + +export class StreamArrayState { + truncate = false; + + private _streamPosition = 0; + private path: Incremental.Path; + private dep = dep(); + + constructor(path: Incremental.Path) { + this.path = path; + } + + depend() { + this.dep(this.path); + } + + get streamPosition() { + return this._streamPosition; + } + + set streamPosition(value) { + if (value !== this._streamPosition) { + this._streamPosition = value; + this.dep.dirty(this.path); + } + } +} diff --git a/src/utilities/internal/__tests__/isDeferredFragment.test.ts b/src/utilities/internal/__tests__/isDeferredFragment.test.ts new file mode 100644 index 00000000000..5440b076e1b --- /dev/null +++ b/src/utilities/internal/__tests__/isDeferredFragment.test.ts @@ -0,0 +1,221 @@ +import type { DocumentNode } from "graphql"; +import { Kind } from "graphql"; + +import { gql } from "@apollo/client"; +import { + getQueryDefinition, + isDeferredFragment, +} from "@apollo/client/utilities/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +function getRootFragment(document: DocumentNode) { + const selection = getQueryDefinition(document).selectionSet.selections[0]; + + invariant( + selection.kind === Kind.FRAGMENT_SPREAD || + selection.kind === Kind.INLINE_FRAGMENT, + "Root selection must be a fragment spread or inline fragment" + ); + + return selection; +} + +test("returns false when the selection has no directives", () => { + const query = gql` + query { + ... on Greeting { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); +}); + +test("returns false when the selection has directives other than @defer", () => { + const query = gql` + query { + ... on Greeting @include(if: true) { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); +}); + +test("returns true for a bare @defer directive", () => { + const query = gql` + query { + ... on Greeting @defer { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(true); +}); + +test("returns true for @defer(if: true)", () => { + const query = gql` + query { + ... on Greeting @defer(if: true) { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(true); +}); + +test("returns false for @defer(if: false)", () => { + const query = gql` + query { + ... on Greeting @defer(if: false) { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); +}); + +test("returns true for @defer(if: $shouldDefer) when the variable is true", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ... on Greeting @defer(if: $shouldDefer) { + name + } + } + `; + + expect( + isDeferredFragment(getRootFragment(query), { shouldDefer: true }) + ).toBe(true); +}); + +test("returns false for @defer(if: $shouldDefer) when the variable is false", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ... on Greeting @defer(if: $shouldDefer) { + name + } + } + `; + + expect( + isDeferredFragment(getRootFragment(query), { shouldDefer: false }) + ).toBe(false); +}); + +test("returns false for @defer(if: $shouldDefer) when the variable is missing", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ... on Greeting @defer(if: $shouldDefer) { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); + expect(isDeferredFragment(getRootFragment(query), undefined)).toBe(false); +}); + +test("returns true for a named fragment spread with bare @defer", () => { + const query = gql` + query { + ...GreetingFields @defer + } + + fragment GreetingFields on Greeting { + name + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(true); +}); + +test("returns false for a named fragment spread with @defer(if: false)", () => { + const query = gql` + query { + ...GreetingFields @defer(if: false) + } + + fragment GreetingFields on Greeting { + name + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); +}); + +test("returns true for a named fragment spread with @defer(if: $shouldDefer) when the variable is true", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ...GreetingFields @defer(if: $shouldDefer) + } + + fragment GreetingFields on Greeting { + name + } + `; + + expect( + isDeferredFragment(getRootFragment(query), { shouldDefer: true }) + ).toBe(true); +}); + +test("returns false for a named fragment spread with @defer(if: $shouldDefer) when the variable is false", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ...GreetingFields @defer(if: $shouldDefer) + } + + fragment GreetingFields on Greeting { + name + } + `; + + expect( + isDeferredFragment(getRootFragment(query), { shouldDefer: false }) + ).toBe(false); +}); + +test("returns true when @defer is present alongside other directives", () => { + const query = gql` + query { + ... on Greeting @include(if: true) @defer { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(true); +}); + +test("returns false when @defer(if: false) is present alongside other directives", () => { + const query = gql` + query { + ... on Greeting @include(if: true) @defer(if: false) { + name + } + } + `; + + expect(isDeferredFragment(getRootFragment(query), {})).toBe(false); +}); + +test("evaluates @defer(if: $shouldDefer) per variables across repeated calls", () => { + const query = gql` + query ($shouldDefer: Boolean!) { + ... on Greeting @defer(if: $shouldDefer) { + name + } + } + `; + const fragment = getRootFragment(query); + + expect(isDeferredFragment(fragment, { shouldDefer: true })).toBe(true); + expect(isDeferredFragment(fragment, { shouldDefer: true })).toBe(true); + expect(isDeferredFragment(fragment, { shouldDefer: false })).toBe(false); +}); diff --git a/src/utilities/internal/__tests__/isStreamField.test.ts b/src/utilities/internal/__tests__/isStreamField.test.ts new file mode 100644 index 00000000000..5f34a2bd9a2 --- /dev/null +++ b/src/utilities/internal/__tests__/isStreamField.test.ts @@ -0,0 +1,155 @@ +import type { DocumentNode } from "graphql"; +import { Kind } from "graphql"; + +import { gql } from "@apollo/client"; +import { + getQueryDefinition, + isStreamField, +} from "@apollo/client/utilities/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +function getRootField(document: DocumentNode) { + const selection = getQueryDefinition(document).selectionSet.selections[0]; + + invariant(selection.kind === Kind.FIELD, "Root selection must be a field"); + + return selection; +} + +test("returns false when the selection has no directives", () => { + const query = gql` + query { + friendList { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(false); +}); + +test("returns false when the selection has directives other than @stream", () => { + const query = gql` + query { + friendList @include(if: true) { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(false); +}); + +test("returns true for a bare @stream directive", () => { + const query = gql` + query { + friendList @stream { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(true); +}); + +test("returns true for @stream(if: true)", () => { + const query = gql` + query { + friendList @stream(if: true) { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(true); +}); + +test("returns false for @stream(if: false)", () => { + const query = gql` + query { + friendList @stream(if: false) { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(false); +}); + +test("returns true for @stream(if: $shouldStream) when the variable is true", () => { + const query = gql` + query ($shouldStream: Boolean!) { + friendList @stream(if: $shouldStream) { + name + } + } + `; + + expect(isStreamField(getRootField(query), { shouldStream: true })).toBe(true); +}); + +test("returns false for @stream(if: $shouldStream) when the variable is false", () => { + const query = gql` + query ($shouldStream: Boolean!) { + friendList @stream(if: $shouldStream) { + name + } + } + `; + + expect(isStreamField(getRootField(query), { shouldStream: false })).toBe( + false + ); +}); + +test("returns false for @stream(if: $shouldStream) when the variable is missing", () => { + const query = gql` + query ($shouldStream: Boolean!) { + friendList @stream(if: $shouldStream) { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(false); + expect(isStreamField(getRootField(query), undefined)).toBe(false); +}); + +test("returns true when @stream is present alongside other directives", () => { + const query = gql` + query { + friendList @include(if: true) @stream { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(true); +}); + +test("returns false when @stream(if: false) is present alongside other directives", () => { + const query = gql` + query { + friendList @include(if: true) @stream(if: false) { + name + } + } + `; + + expect(isStreamField(getRootField(query), {})).toBe(false); +}); + +test("evaluates @stream(if: $shouldStream) per variables across repeated calls", () => { + const query = gql` + query ($shouldStream: Boolean!) { + friendList @stream(if: $shouldStream) { + name + } + } + `; + const field = getRootField(query); + + expect(isStreamField(field, { shouldStream: true })).toBe(true); + expect(isStreamField(field, { shouldStream: true })).toBe(true); + expect(isStreamField(field, { shouldStream: false })).toBe(false); +}); diff --git a/src/utilities/internal/collectSiblingFields.ts b/src/utilities/internal/collectSiblingFields.ts deleted file mode 100644 index d6a182d6f90..00000000000 --- a/src/utilities/internal/collectSiblingFields.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { SelectionSetNode } from "graphql"; -import type { SelectionNode } from "graphql"; -import { Kind } from "graphql"; - -import { DeepMerger } from "./DeepMerger.js"; -import { getFragmentFromSelection } from "./getFragmentFromSelection.js"; -import { isField } from "./isField.js"; -import { isTypenameField } from "./isTypenameField.js"; -import { resultKeyNameFromField } from "./resultKeyNameFromField.js"; -import type { FragmentMap } from "./types/FragmentMap.js"; - -export type FieldMap = { - [fieldName: string]: FieldMap | true; -}; - -interface CollectionContext { - fragmentMap: FragmentMap; - exclude: SelectionNode; -} - -/** - * Returns a map of sibling fields for a selection set. Useful to detect - * overlapping fields. - * - * @internal - */ -export function collectSiblingFields( - selectionSet: SelectionSetNode, - context: CollectionContext, - visitedFragments = new Map() -): FieldMap { - let collectedFieldsMap: FieldMap = {}; - const { fragmentMap } = context; - - for (const selection of selectionSet.selections) { - if (context.exclude === selection) continue; - - if (isField(selection)) { - if (isTypenameField(selection)) continue; - - const name = resultKeyNameFromField(selection); - - if (!selection.selectionSet) { - collectedFieldsMap[name] = true; - continue; - } - - const fieldsForSelection = collectSiblingFields( - selection.selectionSet, - context, - visitedFragments - ); - - collectedFieldsMap[name] = - ( - Object.hasOwn(collectedFieldsMap, name) && - typeof collectedFieldsMap[name] === "object" - ) ? - mergeFieldMaps(collectedFieldsMap[name], fieldsForSelection) - : fieldsForSelection; - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - if (!fragment) continue; - - let fragmentCollectedFieldsMap: FieldMap; - - if (fragment.kind === Kind.FRAGMENT_DEFINITION) { - fragmentCollectedFieldsMap = - visitedFragments.get(fragment.name.value) || - collectSiblingFields( - fragment.selectionSet, - context, - visitedFragments - ); - - visitedFragments.set(fragment.name.value, fragmentCollectedFieldsMap); - } else { - fragmentCollectedFieldsMap = collectSiblingFields( - fragment.selectionSet, - context, - visitedFragments - ); - } - - collectedFieldsMap = mergeFieldMaps( - collectedFieldsMap, - fragmentCollectedFieldsMap - ); - } - } - - return collectedFieldsMap; -} - -function mergeFieldMaps(target: FieldMap, source: FieldMap) { - return new DeepMerger().merge(target, source); -} diff --git a/src/utilities/internal/constants.ts b/src/utilities/internal/constants.ts index 94c60e4f826..1f999191e80 100644 --- a/src/utilities/internal/constants.ts +++ b/src/utilities/internal/constants.ts @@ -20,3 +20,23 @@ export const streamInfoSymbol = Symbol.for("apollo.result.streamInfo"); export const variablesUnknownSymbol = Symbol.for( "apollo.observableQuery.variablesUnknown" ); + +/** + * @internal + * Used to tell `ApolloCache.diff` whether to handle incremental results. This + * changes the behavior of `returnPartialData: false` when handling incremental + * queries with partial or empty data at a `@defer` or `@stream` boundary. This + * also signals to the cache that it should return a data state. + * + * When `handleIncrementalSymbol` is not provided, the cache should behave as it + * does today. + * + * 3rd party caches that want to implement this behavior should talk to the + * Apollo Client team. Open a GitHub issue so we can chat with you on what is + * required for this to work in Apollo Client version 4.x. + * + * Meant for cache implementers only. This should not be used in userland code. + */ +export const handleIncrementalSymbol = Symbol.for( + "apollo.cache.handleIncremental" +); diff --git a/src/utilities/internal/getFragmentFromSelection.ts b/src/utilities/internal/getFragmentFromSelection.ts index c60e2bef341..287a4f1dc20 100644 --- a/src/utilities/internal/getFragmentFromSelection.ts +++ b/src/utilities/internal/getFragmentFromSelection.ts @@ -24,7 +24,7 @@ export function getFragmentFromSelection( } const fragment = fragmentMap && fragmentMap[fragmentName]; invariant(fragment, `No fragment named %s`, fragmentName); - return fragment || null; + return fragment; } default: return null; diff --git a/src/utilities/internal/getMemoryInternals.ts b/src/utilities/internal/getMemoryInternals.ts index 65995b52d54..e06cc7e3978 100644 --- a/src/utilities/internal/getMemoryInternals.ts +++ b/src/utilities/internal/getMemoryInternals.ts @@ -103,6 +103,8 @@ function getCurrentCacheSizes() { const defaults: Record = { canonicalStringify: defaultCacheSizes["canonicalStringify"], checkDocument: defaultCacheSizes["checkDocument"], + isDeferredFragment: defaultCacheSizes["isDeferredFragment"], + isStreamField: defaultCacheSizes["isStreamField"], print: defaultCacheSizes["print"], "documentTransform.cache": defaultCacheSizes["documentTransform.cache"], "queryManager.getDocumentInfo": @@ -124,6 +126,10 @@ function getCurrentCacheSizes() { defaultCacheSizes["inMemoryCache.executeSelectionSet"], "inMemoryCache.executeSubSelectedArray": defaultCacheSizes["inMemoryCache.executeSubSelectedArray"], + "inMemoryCache.prunePartialBoundaries": + defaultCacheSizes["inMemoryCache.prunePartialBoundaries"], + "inMemoryCache.prunePartialStreamArray": + defaultCacheSizes["inMemoryCache.prunePartialStreamArray"], }; return Object.fromEntries( Object.entries(defaults).map(([k, v]) => [ @@ -184,6 +190,12 @@ function _getInMemoryCacheMemoryInternals(this: InMemoryCache) { this["storeReader"]["executeSubSelectedArray"] ), maybeBroadcastWatch: getWrapperInformation(this["maybeBroadcastWatch"]), + prunePartialBoundaries: getWrapperInformation( + this["storeReader"]["prunePartialBoundaries"] + ), + prunePartialStreamArray: getWrapperInformation( + this["storeReader"]["prunePartialStreamArray"] + ), }, fragmentRegistry: { findFragmentSpreads: getWrapperInformation( diff --git a/src/utilities/internal/index.ts b/src/utilities/internal/index.ts index 7bd8f8e6ebd..80d55d4f95e 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -1,7 +1,6 @@ export type { DecoratedPromise } from "./types/DecoratedPromise.js"; export type { DeepOmit } from "./types/DeepOmit.js"; export type { ExtensionsWithStreamInfo } from "./types/ExtensionsWithStreamDetails.js"; -export type { FieldMap } from "./collectSiblingFields.js"; export type { FragmentMap } from "./types/FragmentMap.js"; export type { FragmentMapFunction } from "./types/FragmentMapFunction.js"; export type { FulfilledPromise } from "./types/FulfilledPromise.js"; @@ -14,6 +13,7 @@ export type { Primitive } from "./types/Primitive.js"; export type { RejectedPromise } from "./types/RejectedPromise.js"; export type { RemoveIndexSignature } from "./types/RemoveIndexSignature.js"; export type { StreamInfoTrie } from "./types/StreamInfoTrie.js"; +export type { DeferInfoTrie } from "./types/DeferInfo.js"; export type { VariablesOption } from "./types/VariablesOption.js"; export type { DocumentationTypes } from "./types/DocumentationTypes.js"; export type { LazyType } from "./LazyType.js"; @@ -32,7 +32,6 @@ export { compact } from "./compact.js"; export { createFragmentMap } from "./createFragmentMap.js"; export { createFulfilledPromise } from "./createFulfilledPromise.js"; export { createRejectedPromise } from "./createRejectedPromise.js"; -export { collectSiblingFields } from "./collectSiblingFields.js"; export { dealias } from "./dealias.js"; export { decoratePromise } from "./decoratePromise.js"; export { DeepMerger } from "./DeepMerger.js"; @@ -58,7 +57,9 @@ export { isField } from "./isField.js"; export { isNonEmptyArray } from "./isNonEmptyArray.js"; export { isNonNullObject } from "./isNonNullObject.js"; export { isPlainObject } from "./isPlainObject.js"; +export { isStreamField } from "./isStreamField.js"; export { isTypenameField } from "./isTypenameField.js"; +export { makeStreamInfoTrie } from "./makeStreamInfoTrie.js"; export { makeReference } from "./makeReference.js"; export { makeUniqueId } from "./makeUniqueId.js"; export { maybeDeepFreeze } from "./maybeDeepFreeze.js"; @@ -72,6 +73,7 @@ export { removeMaskedFragmentSpreads } from "./removeFragmentSpreads.js"; export { resultKeyNameFromField } from "./resultKeyNameFromField.js"; export { shouldInclude } from "./shouldInclude.js"; export { storeKeyNameFromField } from "./storeKeyNameFromField.js"; +export { StreamArrayState } from "./StreamArrayState.js"; export { stringifyForDisplay } from "./stringifyForDisplay.js"; export { toQueryResult } from "./toQueryResult.js"; export { filterMap } from "./filterMap.js"; @@ -80,6 +82,7 @@ export { canonicalStringify } from "./canonicalStringify.js"; export { mapObservableFragmentMemoized } from "./mapObservableFragment.js"; export { extensionsSymbol, + handleIncrementalSymbol, streamInfoSymbol, variablesUnknownSymbol, } from "./constants.js"; diff --git a/src/utilities/internal/isDeferredFragment.ts b/src/utilities/internal/isDeferredFragment.ts index 263b61d81c5..60a55c46317 100644 --- a/src/utilities/internal/isDeferredFragment.ts +++ b/src/utilities/internal/isDeferredFragment.ts @@ -3,26 +3,43 @@ import { Kind } from "graphql"; import type { OperationVariables } from "@apollo/client"; -export function isDeferredFragment( - fragmentSelection: InlineFragmentNode | FragmentSpreadNode, - variables: OperationVariables -) { - return !!fragmentSelection.directives?.some((directive) => { - if (directive.name.value !== "defer") { - return false; - } +import { cacheSizes, defaultCacheSizes } from "../caching/sizes.js"; - for (const arg of directive.arguments ?? []) { - if (arg.name.value === "if") { - switch (arg.value.kind) { - case Kind.BOOLEAN: - return arg.value.value; - case Kind.VARIABLE: - return variables[arg.value.name.value]; +import { canonicalStringify } from "./canonicalStringify.js"; +import { memoize } from "./memoize.js"; + +/** @internal */ +export const isDeferredFragment = memoize( + function isDeferredFragment( + fragmentSelection: InlineFragmentNode | FragmentSpreadNode, + variables: OperationVariables | undefined + ) { + return !!fragmentSelection.directives?.some((directive) => { + if (directive.name.value !== "defer") { + return false; + } + + for (const arg of directive.arguments ?? []) { + if (arg.name.value === "if") { + switch (arg.value.kind) { + case Kind.BOOLEAN: + return arg.value.value; + case Kind.VARIABLE: + return !!variables?.[arg.value.name.value]; + } } } - } - return true; - }); -} + return true; + }); + }, + { + max: + cacheSizes["isDeferredFragment"] || + defaultCacheSizes["isDeferredFragment"], + makeCacheKey: ([selection, variables]) => [ + selection, + canonicalStringify(variables), + ], + } +); diff --git a/src/utilities/internal/isStreamField.ts b/src/utilities/internal/isStreamField.ts new file mode 100644 index 00000000000..2cca344bd5b --- /dev/null +++ b/src/utilities/internal/isStreamField.ts @@ -0,0 +1,43 @@ +import type { FieldNode } from "graphql"; +import { Kind } from "graphql"; + +import type { OperationVariables } from "@apollo/client"; + +import { cacheSizes, defaultCacheSizes } from "../caching/sizes.js"; + +import { canonicalStringify } from "./canonicalStringify.js"; +import { memoize } from "./memoize.js"; + +/** @internal */ +export const isStreamField = memoize( + function isStreamField( + field: FieldNode, + variables: OperationVariables | undefined + ) { + return !!field.directives?.some((directive) => { + if (directive.name.value !== "stream") { + return false; + } + + for (const arg of directive.arguments ?? []) { + if (arg.name.value === "if") { + switch (arg.value.kind) { + case Kind.BOOLEAN: + return arg.value.value; + case Kind.VARIABLE: + return !!variables?.[arg.value.name.value]; + } + } + } + + return true; + }); + }, + { + max: cacheSizes["isStreamField"] || defaultCacheSizes["isStreamField"], + makeCacheKey: ([selection, variables]) => [ + selection, + canonicalStringify(variables), + ], + } +); diff --git a/src/utilities/internal/makeStreamInfoTrie.ts b/src/utilities/internal/makeStreamInfoTrie.ts new file mode 100644 index 00000000000..f53aa5904a0 --- /dev/null +++ b/src/utilities/internal/makeStreamInfoTrie.ts @@ -0,0 +1,12 @@ +import { Trie } from "@wry/trie"; + +import { StreamArrayState } from "./StreamArrayState.js"; +import type { StreamInfoTrie } from "./types/StreamInfoTrie.js"; + +/** @internal */ +export function makeStreamInfoTrie(): StreamInfoTrie { + return new Trie(false, (path) => ({ + state: new StreamArrayState(path), + current: { isFirstChunk: true, isLastChunk: false }, + })); +} diff --git a/src/utilities/internal/types/DeferInfo.ts b/src/utilities/internal/types/DeferInfo.ts new file mode 100644 index 00000000000..a81cc9b48e2 --- /dev/null +++ b/src/utilities/internal/types/DeferInfo.ts @@ -0,0 +1,8 @@ +import type { Trie } from "@wry/trie"; + +/** + * @internal + * For use in InMemoryCache only. This should not be used in userland + * code. + */ +export type DeferInfoTrie = Trie; diff --git a/src/utilities/internal/types/StreamInfoTrie.ts b/src/utilities/internal/types/StreamInfoTrie.ts index 59e95c9f88e..975c6f3f34c 100644 --- a/src/utilities/internal/types/StreamInfoTrie.ts +++ b/src/utilities/internal/types/StreamInfoTrie.ts @@ -2,9 +2,12 @@ import type { Trie } from "@wry/trie"; import type { Incremental } from "@apollo/client/incremental"; +import type { StreamArrayState } from "../StreamArrayState.js"; + /** @internal */ export type StreamInfoTrie = Trie<{ current: Incremental.StreamFieldInfo; + state: StreamArrayState; previous?: { incoming: unknown; streamFieldInfo: Incremental.StreamFieldInfo;