diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 0e7551ac5dc..3ff92332c84 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -94,6 +94,7 @@ export abstract class ApolloCache { abstract removeOptimistic(id: string): void; // (undocumented) abstract reset(options?: Cache_2.ResetOptions): Promise; + resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; @@ -544,6 +545,8 @@ export class InMemoryCache extends ApolloCache { // (undocumented) reset(options?: Cache_2.ResetOptions): Promise; // (undocumented) + resolvesClientField(typename: string, fieldName: string): boolean; + // (undocumented) restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index c1f15c36f34..ab7effb8971 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -195,6 +195,13 @@ export namespace ApolloClient { } } // (undocumented) + export interface Experiment { + // (undocumented) + (this: ApolloClient, options: ApolloClient.Options): void; + // (undocumented) + v: 1; + } + // (undocumented) export type MutateOptions = { optimisticResponse?: Unmasked> | ((vars: TVariables, { IGNORE }: { IGNORE: IgnoreModifier; @@ -231,6 +238,7 @@ export namespace ApolloClient { documentTransform?: DocumentTransform; // (undocumented) enhancedClientAwareness?: ClientAwarenessLink.EnhancedClientAwarenessOptions; + experiments?: ApolloClient.Experiment[]; incrementalHandler?: Incremental.Handler; link: ApolloLink; // (undocumented) @@ -1132,8 +1140,8 @@ export type WatchQueryOptions>; } // (undocumented) - type IncrementalDeferPayload> = { - data?: TData | null | undefined; + type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; // (undocumented) + type IncrementalResult> = IncrementalDeferResult | IncrementalStreamResult; + // (undocumented) + type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + // (undocumented) type InitialResult> = { data?: TData | null | undefined; errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; // (undocumented) type SubsequentResult> = { @@ -45,7 +56,7 @@ namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; // (undocumented) interface TypeOverrides { @@ -80,6 +91,102 @@ class DeferRequest> implements Incremental hasNext: boolean; } +// @public (undocumented) +export namespace GraphQL17Alpha9Handler { + // (undocumented) + export type Chunk = InitialResult | SubsequentResult; + // (undocumented) + export interface CompletedResult { + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + id: string; + } + // (undocumented) + export interface GraphQL17Alpha9Result extends HKT { + // (undocumented) + arg1: unknown; + // (undocumented) + arg2: unknown; + // (undocumented) + return: GraphQL17Alpha9Handler.Chunk>; + } + // (undocumented) + export interface IncrementalDeferResult> { + // (undocumented) + data: TData; + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + extensions?: Record; + // (undocumented) + id: string; + // (undocumented) + subPath?: Incremental.Path; + } + // (undocumented) + export type IncrementalResult = IncrementalDeferResult | IncrementalStreamResult; + // (undocumented) + export interface IncrementalStreamResult> { + // (undocumented) + errors?: ReadonlyArray; + // (undocumented) + extensions?: Record; + // (undocumented) + id: string; + // (undocumented) + items: TData; + // (undocumented) + subPath?: Incremental.Path; + } + // (undocumented) + export type InitialResult> = { + data: TData; + errors?: ReadonlyArray; + pending: ReadonlyArray; + hasNext: boolean; + extensions?: Record; + }; + // (undocumented) + export interface PendingResult { + // (undocumented) + id: string; + // (undocumented) + label?: string; + // (undocumented) + path: Incremental.Path; + } + // (undocumented) + export type SubsequentResult = { + hasNext: boolean; + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + extensions?: Record; + }; + // (undocumented) + export interface TypeOverrides { + // (undocumented) + AdditionalApolloLinkResultTypes: GraphQL17Alpha9Result; + } +} + +// @public +export class GraphQL17Alpha9Handler implements Incremental.Handler> { + // @internal @deprecated (undocumented) + extractErrors(result: ApolloLink.Result): GraphQLFormattedError[] | undefined; + // @internal @deprecated (undocumented) + isIncrementalResult(result: ApolloLink.Result): result is GraphQL17Alpha9Handler.InitialResult | GraphQL17Alpha9Handler.SubsequentResult; + // @internal @deprecated (undocumented) + prepareRequest(request: ApolloLink.Request): ApolloLink.Request; + // Warning: (ae-forgotten-export) The symbol "IncrementalRequest" needs to be exported by the entry point index.d.ts + // + // @internal @deprecated (undocumented) + startRequest(_: { + query: DocumentNode; + }): IncrementalRequest; +} + // @public (undocumented) export namespace Incremental { // @internal @deprecated (undocumented) @@ -106,6 +213,14 @@ export namespace Incremental { export type Path = ReadonlyArray; } +// @public (undocumented) +class IncrementalRequest implements Incremental.IncrementalRequest, TData> { + // (undocumented) + handle(cacheData: TData | DeepPartial | null | undefined, chunk: GraphQL17Alpha9Handler.Chunk): FormattedExecutionResult; + // (undocumented) + hasNext: boolean; +} + // @public (undocumented) export namespace NotImplementedHandler { // (undocumented) diff --git a/.api-reports/api-report-local-state.api.md b/.api-reports/api-report-local-state.api.md index 747567da303..4a633e1a00f 100644 --- a/.api-reports/api-report-local-state.api.md +++ b/.api-reports/api-report-local-state.api.md @@ -14,6 +14,7 @@ import type { NoInfer as NoInfer_2 } from '@apollo/client/utilities/internal'; import type { OperationVariables } from '@apollo/client'; import type { RemoveIndexSignature } from '@apollo/client/utilities/internal'; import type { TypedDocumentNode } from '@apollo/client'; +import type { WatchQueryFetchPolicy } from '@apollo/client'; // @public (undocumented) type InferContextValueFromResolvers = TResolvers extends { @@ -91,7 +92,7 @@ export class LocalState({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: { + execute({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; context: DefaultContext | undefined; @@ -99,6 +100,7 @@ export class LocalState>; // (undocumented) getExportedVariables({ document, client, context, variables, }: { diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 213a6882526..9861b908b08 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -639,7 +639,9 @@ export namespace useMutation { } ]) => Promise>>; // (undocumented) - export type MutationFunctionOptions = Options; + export type MutationFunctionOptions = Options & { + context?: DefaultContext | ((hookContext: DefaultContext | undefined) => DefaultContext); + }; // (undocumented) export interface Options = Partial> { awaitRefetchQueries?: boolean; diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index f4329853077..790b197514d 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -100,9 +100,20 @@ export type DecoratedPromise = PendingPromise | FulfilledPromise export function decoratePromise(promise: Promise): DecoratedPromise; // @internal @deprecated (undocumented) -export class DeepMerger { +export namespace DeepMerger { + // (undocumented) + export type ArrayMergeStrategy = "truncate" | "combine"; + // (undocumented) + export interface Options { + // (undocumented) + arrayMerge?: DeepMerger.ArrayMergeStrategy; + } +} + +// @internal @deprecated (undocumented) +export class DeepMerger { // Warning: (ae-forgotten-export) The symbol "ReconcilerFunction" needs to be exported by the entry point index.d.ts - constructor(reconciler?: ReconcilerFunction); + constructor(reconciler?: ReconcilerFunction, options?: DeepMerger.Options); // (undocumented) isObject: typeof isNonNullObject; // (undocumented) @@ -449,7 +460,7 @@ export type VariablesOption = {} extends // Warnings were encountered during analysis: // -// src/utilities/internal/getStoreKeyName.ts:88:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts +// src/utilities/internal/getStoreKeyName.ts:89:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index fa731a1b8b6..ddcaa569a04 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -95,6 +95,7 @@ export abstract class ApolloCache { abstract removeOptimistic(id: string): void; // (undocumented) abstract reset(options?: Cache_2.ResetOptions): Promise; + resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; @@ -201,6 +202,13 @@ export namespace ApolloClient { variables?: TVariables; } } + // (undocumented) + export interface Experiment { + // (undocumented) + (this: ApolloClient, options: ApolloClient.Options): void; + // (undocumented) + v: 1; + } // Warning: (ae-forgotten-export) The symbol "VariablesOption" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -242,6 +250,7 @@ export namespace ApolloClient { documentTransform?: DocumentTransform; // (undocumented) enhancedClientAwareness?: ClientAwarenessLink.EnhancedClientAwarenessOptions; + experiments?: ApolloClient.Experiment[]; // Warning: (ae-forgotten-export) The symbol "Incremental" needs to be exported by the entry point index.d.ts incrementalHandler?: Incremental.Handler; link: ApolloLink; @@ -1373,6 +1382,8 @@ export class InMemoryCache extends ApolloCache { // (undocumented) reset(options?: Cache_2.ResetOptions): Promise; // (undocumented) + resolvesClientField(typename: string, fieldName: string): boolean; + // (undocumented) restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; @@ -1569,7 +1580,7 @@ class LocalState({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: { + execute({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; context: DefaultContext | undefined; @@ -1577,6 +1588,7 @@ class LocalState>; // (undocumented) getExportedVariables({ document, client, context, variables, }: { @@ -2711,13 +2723,13 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:134:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/core/ApolloClient.ts:159:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts -// src/core/ApolloClient.ts:353:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts -// src/core/ObservableQuery.ts:361:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts +// src/core/ApolloClient.ts:168:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts +// src/core/ApolloClient.ts:362:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts +// src/core/ObservableQuery.ts:368:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:180:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:147:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:200:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts -// src/local-state/LocalState.ts:243:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:149:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:202:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts +// src/local-state/LocalState.ts:245:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/big-flowers-move.md b/.changeset/big-flowers-move.md new file mode 100644 index 00000000000..c80ffc762f5 --- /dev/null +++ b/.changeset/big-flowers-move.md @@ -0,0 +1,23 @@ +--- +"@apollo/client": minor +--- + +You can now provide a callback function as the `context` option on the `mutate` function returned by `useMutation`. The callback function is called with the value of the `context` option provided to the `useMutation` hook. This is useful if you'd like to merge the context object provided to the `useMutation` hook with a value provided to the `mutate` function. + + +```ts +function MyComponent() { + const [mutate, result] = useMutation(MUTATION, { + context: { foo: true } + }); + + async function runMutation() { + await mutate({ + // sends context as { foo: true, bar: true } + context: (hookContext) => ({ ...hookContext, bar: true }) + }); + } + + // ... +} +``` diff --git a/.changeset/cold-kiwis-give.md b/.changeset/cold-kiwis-give.md new file mode 100644 index 00000000000..880998840fe --- /dev/null +++ b/.changeset/cold-kiwis-give.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Fix an issue where deferred payloads that reteurned arrays with fewer items than the original cached array would retain items from the cached array. This change includes `@stream` arrays where stream arrays replace the cached arrays. diff --git a/.changeset/flat-worms-notice.md b/.changeset/flat-worms-notice.md new file mode 100644 index 00000000000..6833bc32800 --- /dev/null +++ b/.changeset/flat-worms-notice.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Don't set the fallback value of a `@client` field to `null` when a `read` function is defined. Instead the `read` function will be called with an `existing` value of `undefined` to allow default arguments to be used to set the returned value. + +When a `read` function is not defined nor is there a defined resolver for the field, warn and set the value to `null` only in that instance. diff --git a/.changeset/funny-bats-hammer.md b/.changeset/funny-bats-hammer.md new file mode 100644 index 00000000000..9848f45763b --- /dev/null +++ b/.changeset/funny-bats-hammer.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where calling `fetchMore` with `@defer` or `@stream` would not rerender incremental results as they were streamed. diff --git a/.changeset/little-yaks-decide.md b/.changeset/little-yaks-decide.md new file mode 100644 index 00000000000..53aa1d9cd75 --- /dev/null +++ b/.changeset/little-yaks-decide.md @@ -0,0 +1,17 @@ +--- +"@apollo/client": minor +--- + +Support the newer incremental delivery format for the `@defer` directive implemented in `graphql@17.0.0-alpha.9`. Import the `GraphQL17Alpha9Handler` to use the newer incremental delivery format with `@defer`. + +```ts +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; + +const client = new ApolloClient({ + // ... + incrementalHandler: new GraphQL17Alpha9Handler(), +}); +``` + +> [!NOTE] +> In order to use the `GraphQL17Alpha9Handler`, the GraphQL server MUST implement the newer incremental delivery format. You may see errors or unusual behavior if you use the wrong handler. If you are using Apollo Router, continue to use the `Defer20220824Handler` because Apollo Router does not yet support the newer incremental delivery format. diff --git a/.changeset/neat-lemons-shave.md b/.changeset/neat-lemons-shave.md new file mode 100644 index 00000000000..d7357691800 --- /dev/null +++ b/.changeset/neat-lemons-shave.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Improve the cache data loss warning message when `existing` or `incoming` is an array. diff --git a/.changeset/olive-queens-fold.md b/.changeset/olive-queens-fold.md new file mode 100644 index 00000000000..ea3abc75a0c --- /dev/null +++ b/.changeset/olive-queens-fold.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Create mechanism to add experimental features to Apollo Client diff --git a/.changeset/perfect-crabs-smile.md b/.changeset/perfect-crabs-smile.md new file mode 100644 index 00000000000..d85e58e885f --- /dev/null +++ b/.changeset/perfect-crabs-smile.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure `LocalState` doesn't try to read from the cache when using a `no-cache` fetch policy. diff --git a/.changeset/popular-files-glow.md b/.changeset/popular-files-glow.md new file mode 100644 index 00000000000..53955edd598 --- /dev/null +++ b/.changeset/popular-files-glow.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure an error is thrown when `@stream` is detected and an `incrementalDelivery` handler is not configured. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000000..73dac31dce9 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,23 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@apollo/client": "4.0.4", + "@apollo/client-graphql-codegen": "1.0.0", + "@apollo/client-codemod-migrate-3-to-4": "1.0.2" + }, + "changesets": [ + "big-flowers-move", + "cold-kiwis-give", + "flat-worms-notice", + "funny-bats-hammer", + "little-yaks-decide", + "neat-lemons-shave", + "olive-queens-fold", + "perfect-crabs-smile", + "popular-files-glow", + "shaggy-islands-yell", + "six-islands-drum", + "unlucky-cooks-rhyme" + ] +} diff --git a/.changeset/shaggy-islands-yell.md b/.changeset/shaggy-islands-yell.md new file mode 100644 index 00000000000..0056f773493 --- /dev/null +++ b/.changeset/shaggy-islands-yell.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Warn when using a `no-cache` fetch policy without a local resolver defined. `no-cache` queries do not read or write to the cache which meant `no-cache` queries are silently incomplete when the `@client` field value was handled by a cache `read` function. diff --git a/.changeset/six-islands-drum.md b/.changeset/six-islands-drum.md new file mode 100644 index 00000000000..e540e2b375c --- /dev/null +++ b/.changeset/six-islands-drum.md @@ -0,0 +1,8 @@ +--- +"@apollo/client": minor +--- + +Add support for the `@stream` directive on both the `Defer20220824Handler` and the `GraphQL17Alpha2Handler`. + +> [!NOTE] +> The implementations of `@stream` differ in the delivery of incremental results between the different GraphQL spec versions. If you upgrading from the older format to the newer format, expect the timing of some incremental results to change. diff --git a/.changeset/slimy-ducks-scream.md b/.changeset/slimy-ducks-scream.md new file mode 100644 index 00000000000..66187335a80 --- /dev/null +++ b/.changeset/slimy-ducks-scream.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Update the `accept` header used with the `GraphQL17Alpha9Handler` to `multipart/mixed;incrementalSpec=v0.2` to ensure the newest incremental delivery format is requested. diff --git a/.changeset/unlucky-cooks-rhyme.md b/.changeset/unlucky-cooks-rhyme.md new file mode 100644 index 00000000000..c73a23c930c --- /dev/null +++ b/.changeset/unlucky-cooks-rhyme.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Add an abstract `resolvesClientField` function to `ApolloCache` that can be used by caches to tell `LocalState` if it can resolve a `@client` field when a local resolver is not defined. + +`LocalState` will emit a warning and set a fallback value of `null` when no local resolver is defined and `resolvesClientField` returns `false`, or isn't defined. Returning `true` from `resolvesClientField` signals that a mechanism in the cache will set the field value. In this case, `LocalState` won't set the field value. diff --git a/.prettierrc b/.prettierrc index 8a0e9b37b39..5e21b9169ee 100644 --- a/.prettierrc +++ b/.prettierrc @@ -17,6 +17,12 @@ "parser": "typescript-with-jsdoc" } }, + { + "files": ["**/__tests__/**/*.ts", "**/__tests__/**/*.tsx"], + "options": { + "parser": "typescript" + } + }, { "files": ["*.mdx"], "options": { diff --git a/.size-limits.json b/.size-limits.json index 7f303c892bf..6671f69ef70 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43857, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38699, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33415, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27498 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 44831, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39452, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33875, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27756 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0711d36e851..e056190bf04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # @apollo/client +## 4.1.0-alpha.2 + +### Minor Changes + +- [#12959](https://github.com/apollographql/apollo-client/pull/12959) [`556e837`](https://github.com/apollographql/apollo-client/commit/556e83781069d925a7e8f99e49023f6f858c6438) Thanks [@jerelmiller](https://github.com/jerelmiller)! - You can now provide a callback function as the `context` option on the `mutate` function returned by `useMutation`. The callback function is called with the value of the `context` option provided to the `useMutation` hook. This is useful if you'd like to merge the context object provided to the `useMutation` hook with a value provided to the `mutate` function. + + ```ts + function MyComponent() { + const [mutate, result] = useMutation(MUTATION, { + context: { foo: true }, + }); + + async function runMutation() { + await mutate({ + // sends context as { foo: true, bar: true } + context: (hookContext) => ({ ...hookContext, bar: true }), + }); + } + + // ... + } + ``` + +### Patch Changes + +- [#12954](https://github.com/apollographql/apollo-client/pull/12954) [`1c82eaf`](https://github.com/apollographql/apollo-client/commit/1c82eafe4921a9e30128202623be6c5a3d4df803) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure an error is thrown when `@stream` is detected and an `incrementalDelivery` handler is not configured. + +## 4.1.0-alpha.1 + +### Minor Changes + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Don't set the fallback value of a `@client` field to `null` when a `read` function is defined. Instead the `read` function will be called with an `existing` value of `undefined` to allow default arguments to be used to set the returned value. + + When a `read` function is not defined nor is there a defined resolver for the field, warn and set the value to `null` only in that instance. + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add an abstract `resolvesClientField` function to `ApolloCache` that can be used by caches to tell `LocalState` if it can resolve a `@client` field when a local resolver is not defined. + + `LocalState` will emit a warning and set a fallback value of `null` when no local resolver is defined and `resolvesClientField` returns `false`, or isn't defined. Returning `true` from `resolvesClientField` signals that a mechanism in the cache will set the field value. In this case, `LocalState` won't set the field value. + +### Patch Changes + +- [#12915](https://github.com/apollographql/apollo-client/pull/12915) [`c97b145`](https://github.com/apollographql/apollo-client/commit/c97b145188d39d754ff098ff399a80cae5b10cc0) Thanks [@phryneas](https://github.com/phryneas)! - Create mechanism to add experimental features to Apollo Client + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure `LocalState` doesn't try to read from the cache when using a `no-cache` fetch policy. + +- [#12934](https://github.com/apollographql/apollo-client/pull/12934) [`54ab6d9`](https://github.com/apollographql/apollo-client/commit/54ab6d994692dad9f06d3d0b84c84d021d126577) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Warn when using a `no-cache` fetch policy without a local resolver defined. `no-cache` queries do not read or write to the cache which meant `no-cache` queries are silently incomplete when the `@client` field value was handled by a cache `read` function. + +## 4.1.0-alpha.0 + +### Minor Changes + +- [#12923](https://github.com/apollographql/apollo-client/pull/12923) [`2aa31c7`](https://github.com/apollographql/apollo-client/commit/2aa31c718155e88814551afb14fd7a0035acc57d) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix an issue where deferred payloads that reteurned arrays with fewer items than the original cached array would retain items from the cached array. This change includes `@stream` arrays where stream arrays replace the cached arrays. + +- [#12926](https://github.com/apollographql/apollo-client/pull/12926) [`c7fba99`](https://github.com/apollographql/apollo-client/commit/c7fba99e16da522fdbc35b9c16cdb8df0dda4c2c) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Support the newer incremental delivery format for the `@defer` directive implemented in `graphql@17.0.0-alpha.9`. Import the `GraphQL17Alpha9Handler` to use the newer incremental delivery format with `@defer`. + + ```ts + import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; + + const client = new ApolloClient({ + // ... + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + ``` + + > [!NOTE] + > In order to use the `GraphQL17Alpha9Handler`, the GraphQL server MUST implement the newer incremental delivery format. You may see errors or unusual behavior if you use the wrong handler. If you are using Apollo Router, continue to use the `Defer20220824Handler` because Apollo Router does not yet support the newer incremental delivery format. + +- [#12918](https://github.com/apollographql/apollo-client/pull/12918) [`562e219`](https://github.com/apollographql/apollo-client/commit/562e2191a4b38e05edb3da9074e2958db3c7b6b9) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Add support for the `@stream` directive on both the `Defer20220824Handler` and the `GraphQL17Alpha2Handler`. + + > [!NOTE] + > The implementations of `@stream` differ in the delivery of incremental results between the different GraphQL spec versions. If you upgrading from the older format to the newer format, expect the timing of some incremental results to change. + +### Patch Changes + +- [#12925](https://github.com/apollographql/apollo-client/pull/12925) [`f538a83`](https://github.com/apollographql/apollo-client/commit/f538a83621e1d110286c056dd8e91611dfd9a1d3) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix an issue where calling `fetchMore` with `@defer` or `@stream` would not rerender incremental results as they were streamed. + +- [#12923](https://github.com/apollographql/apollo-client/pull/12923) [`01cace0`](https://github.com/apollographql/apollo-client/commit/01cace0a6d4faf79e8a4188b93c7d13c4b26d6d4) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Improve the cache data loss warning message when `existing` or `incoming` is an array. + ## 4.0.4 ### Patch Changes diff --git a/config/jest.config.ts b/config/jest.config.ts index 9e8be6190dc..4cd48b1fd7c 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -49,8 +49,11 @@ const react17TestFileIgnoreList = [ "src/testing/experimental/__tests__/createTestSchema.test.tsx", "src/react/hooks/__tests__/useSuspenseFragment.test.tsx", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", + "src/react/hooks/__tests__/useSuspenseQuery/*", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", + "src/react/hooks/__tests__/useBackgroundQuery/*", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", + "src/react/hooks/__tests__/useLoadableQuery/*", "src/react/hooks/__tests__/useQueryRefHandlers.test.tsx", "src/react/query-preloader/__tests__/createQueryPreloader.test.tsx", "src/react/ssr/__tests__/prerenderStatic.test.tsx", diff --git a/docs/source/data/mutations.mdx b/docs/source/data/mutations.mdx index f12a780c3b2..15fe52b449e 100644 --- a/docs/source/data/mutations.mdx +++ b/docs/source/data/mutations.mdx @@ -156,6 +156,31 @@ When using TypeScript, you might see an error related to a missing variable when + + +##### Merging `context` from the hook and `mutate` function + + + +Due to option precedence, `context` provided to the `mutate` function overrides `context` provided to the `useMutation` hook. In some cases, you might want to merge the `context` value provided to the hook with a value available at the time you execute the `mutate` function. + +You accomplish this by using a callback function for the `context` option provided to the `mutate` function. The callback function is called with the `context` value provided to the hook, allowing you to merge them together. + +```ts +addTodo({ + context: (hookContext) => ({ + ...hookContext, + myCustomValue: true, + }), +}); +``` + + + +Your callback function is not required to merge the context values together. The `context` value sent to the link chain is the value returned from the function which makes it possible to change the `context` value in any way you wish, such as omitting a property from the hook context. + + + ### Tracking mutation status In addition to a mutate function, the `useMutation` hook returns an object that represents the current state of the mutation's execution. The fields of this object include booleans that indicate whether the mutate function has been `called` and whether the mutation's result is currently `loading`. diff --git a/package-lock.json b/package-lock.json index bd60c4e9976..5ca228932a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.2", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -82,6 +82,7 @@ "globals": "15.14.0", "graphql": "16.9.0", "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", + "graphql-17-alpha9": "npm:graphql@17.0.0-alpha.9", "graphql-ws": "6.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", @@ -11498,6 +11499,17 @@ "node": "^14.19.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/graphql-17-alpha9": { + "name": "graphql", + "version": "17.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.0-alpha.9.tgz", + "integrity": "sha512-jVK1BsvX5pUIEpRDlEgeKJr80GAxl3B8ISsFDjXHtl2xAxMXVGTEFF4Q4R8NH0Gw7yMwcHDndkNjoNT5CbwHKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.19.0 || ^18.14.0 || >=19.7.0" + } + }, "node_modules/graphql-config": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.5.tgz", diff --git a/package.json b/package.json index 71218fb900e..37d59341ada 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "4.0.4", + "version": "4.1.0-alpha.2", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ @@ -214,6 +214,7 @@ "globals": "15.14.0", "graphql": "16.9.0", "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", + "graphql-17-alpha9": "npm:graphql@17.0.0-alpha.9", "graphql-ws": "6.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", diff --git a/patches/graphql-17-alpha9+17.0.0-alpha.9.patch b/patches/graphql-17-alpha9+17.0.0-alpha.9.patch new file mode 100644 index 00000000000..591af1a11f4 --- /dev/null +++ b/patches/graphql-17-alpha9+17.0.0-alpha.9.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/graphql-17-alpha9/execution/types.d.ts b/node_modules/graphql-17-alpha9/execution/types.d.ts +index 48ef2e9..6ef2ab3 100644 +--- a/node_modules/graphql-17-alpha9/execution/types.d.ts ++++ b/node_modules/graphql-17-alpha9/execution/types.d.ts +@@ -95,9 +95,8 @@ export interface CompletedResult { + errors?: ReadonlyArray; + } + export interface FormattedCompletedResult { +- path: ReadonlyArray; +- label?: string; +- errors?: ReadonlyArray; ++ id: string; ++ errors?: ReadonlyArray; + } + export declare function isPendingExecutionGroup(incrementalDataRecord: IncrementalDataRecord): incrementalDataRecord is PendingExecutionGroup; + export type CompletedExecutionGroup = SuccessfulExecutionGroup | FailedExecutionGroup; diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index b491ae75741..a0381c9daef 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -3052,7 +3052,28 @@ describe("ApolloClient", () => { await expect(() => client.query({ query })).rejects.toThrow( new InvariantError( - "`@defer` is not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + ) + ); + }); + + test("will error when used with `@stream` in a without specifying an incremental strategy", async () => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const query = gql` + query { + items @stream { + bar + } + } + `; + + await expect(() => client.query({ query })).rejects.toThrow( + new InvariantError( + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." ) ); }); diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index c7343506bff..2dd66642204 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -145,6 +145,7 @@ exports[`exports of public entry points @apollo/client/incremental 1`] = ` Array [ "Defer20220824Handler", "GraphQL17Alpha2Handler", + "GraphQL17Alpha9Handler", "NotImplementedHandler", ] `; @@ -356,14 +357,21 @@ Array [ "ObservableStream", "actAsync", "addDelayToMocks", + "asyncIterableSubject", "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", "enableFakeTimers", + "executeSchemaGraphQL17Alpha2", + "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", + "friendListSchemaGraphQL17Alpha2", + "friendListSchemaGraphQL17Alpha9", "markAsStreaming", - "mockDeferStream", + "mockDefer20220824", + "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "promiseWithResolvers", "renderAsync", "renderHookAsync", "resetApolloContext", diff --git a/src/__tests__/fetchMore.ts b/src/__tests__/fetchMore.ts index 73fdf2af688..7c863397dc1 100644 --- a/src/__tests__/fetchMore.ts +++ b/src/__tests__/fetchMore.ts @@ -19,7 +19,7 @@ import { Defer20220824Handler } from "@apollo/client/incremental"; import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; import { markAsStreaming, - mockDeferStream, + mockDefer20220824, ObservableStream, setupPaginatedCase, } from "@apollo/client/testing/internal"; @@ -2478,7 +2478,7 @@ test("uses updateQuery to update the result of the query with no-cache queries", }); test("calling `fetchMore` on an ObservableQuery that hasn't finished deferring yet will not put it into completed state", async () => { - const defer = mockDeferStream(); + const defer = mockDefer20220824(); const baseLink = new MockSubscriptionLink(); const client = new ApolloClient({ diff --git a/src/__tests__/local-state/general.ts b/src/__tests__/local-state/general.ts index 69281772d3b..6771eab00c5 100644 --- a/src/__tests__/local-state/general.ts +++ b/src/__tests__/local-state/general.ts @@ -26,6 +26,13 @@ import { } from "@apollo/client/testing/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; +const WARNINGS = { + MISSING_RESOLVER: + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + NO_CACHE: + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", +}; + describe("General functionality", () => { test("should not impact normal non-@client use", async () => { const query = gql` @@ -632,7 +639,7 @@ describe("Cache manipulation", () => { }); expect(read).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledWith(null, expect.anything()); + expect(read).toHaveBeenCalledWith(undefined, expect.anything()); expect(console.warn).not.toHaveBeenCalled(); }); }); @@ -1510,3 +1517,149 @@ test("throws when executing subscriptions with client fields when local state is ) ); }); + +test.each(["cache-first", "network-only"] as const)( + "sets existing value of `@client` field to undefined when read function is present", + async (fetchPolicy) => { + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const read = jest.fn((value = "Fallback") => value); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + read, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect( + client.query({ query, fetchPolicy }) + ).resolves.toStrictEqualTyped({ + data: { + user: { __typename: "User", firstName: "Fallback", lastName: "Smith" }, + }, + }); + + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith(undefined, expect.anything()); + } +); + +test("sets existing value of `@client` field to null and warns when using no-cache with read function", async () => { + using _ = spyOnConsole("warn"); + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const read = jest.fn((value) => value ?? "Fallback"); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + read, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect( + client.query({ query, fetchPolicy: "no-cache" }) + ).resolves.toStrictEqualTyped({ + data: { + user: { __typename: "User", firstName: null, lastName: "Smith" }, + }, + }); + + expect(read).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.NO_CACHE, + "User.firstName" + ); +}); + +test("sets existing value of `@client` field to null and warns when merge function but not read function is present", async () => { + using _ = spyOnConsole("warn"); + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const merge = jest.fn(() => "Fallback"); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + User: { + fields: { + firstName: { + merge, + }, + }, + }, + }, + }), + link: new ApolloLink(() => { + return of({ + data: { user: { __typename: "User", lastName: "Smith" } }, + }).pipe(delay(10)); + }), + localState: new LocalState(), + }); + + await expect(client.query({ query })).resolves.toStrictEqualTyped({ + data: { + user: { + __typename: "User", + firstName: "Fallback", + lastName: "Smith", + }, + }, + }); + + expect(merge).toHaveBeenCalledTimes(1); + expect(merge).toHaveBeenCalledWith(undefined, null, expect.anything()); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.firstName" + ); +}); diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index ac80f48f6cd..3e98626633d 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -178,6 +178,28 @@ export abstract class ApolloCache { return null; } + // Local state API + + /** + * Determines whether a `@client` field can be resolved by the cache. Used + * when `LocalState` does not have a local resolver that can resolve the + * field. + * + * @remarks Cache implementations should return `true` if a mechanism in the + * cache is expected to provide a value for the field. `LocalState` will set + * the value of the field to `undefined` in order for the cache to handle it. + * + * Cache implementations should return `false` to indicate that it cannot + * handle resolving the field (either because it doesn't have a mechanism to + * do so, or because the user hasn't provided enough information to resolve + * the field). Returning `false` will emit a warning and set the value of the + * field to `null`. + * + * A cache that doesn't implement `resolvesClientField` will be treated the + * same as returning `false`. + */ + public resolvesClientField?(typename: string, fieldName: string): boolean; + // Transactional API // The batch method is intended to replace/subsume both performTransaction diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 2a16c513bc5..26eedf301bf 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1042,6 +1042,49 @@ describe("Cache", () => { }); } ); + + it("does not write @stream directive as part of the cache key", () => { + const cache = new InMemoryCache(); + + cache.writeQuery({ + data: { + list: [{ __typename: "Item", id: "1", value: 1 }], + }, + query: gql` + query { + list @stream(initialCount: 1) { + id + value + } + } + `, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + list: [{ __ref: "Item:1" }], + }, + "Item:1": { __typename: "Item", id: "1", value: 1 }, + }); + + // We should be able to read the list without the `@stream` directive and + // get back results + expect( + cache.readQuery({ + query: gql` + query { + list { + id + value + } + } + `, + }) + ).toStrictEqualTyped({ + list: [{ __typename: "Item", id: "1", value: 1 }], + }); + }); }); describe("writeFragment", () => { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index f7d90e1169e..102ee0b2f51 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -530,6 +530,10 @@ export class InMemoryCache extends ApolloCache { return this.config.fragments?.lookup(fragmentName) || null; } + public resolvesClientField(typename: string, fieldName: string): boolean { + return !!this.policies.getReadFunction(typename, fieldName); + } + protected broadcastWatches(options?: BroadcastOptions) { if (!this.txCount) { this.watches.forEach((c) => this.maybeBroadcastWatch(c, options)); diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index b44b6eb02f6..d852dc06248 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -894,8 +894,8 @@ For more information about these options, please refer to the documentation: " have an ID or a custom merge function, or " : "", typeDotName, - { ...existing }, - { ...incoming } + Array.isArray(existing) ? [...existing] : { ...existing }, + Array.isArray(incoming) ? [...incoming] : { ...incoming } ); } diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 730012610bd..0089eb2b32e 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -132,6 +132,15 @@ export declare namespace ApolloClient { * queries. */ incrementalHandler?: Incremental.Handler; + + /** + * @experimental + * Allows passing in "experiments", experimental features that might one day + * become part of Apollo Client's core functionality. + * Keep in mind that these features might change the core of Apollo Client. + * Do not pass in experiments that are not provided by Apollo. + */ + experiments?: ApolloClient.Experiment[]; } interface DevtoolsOptions { @@ -610,6 +619,11 @@ export declare namespace ApolloClient { variables?: TVariables; } } + + export interface Experiment { + (this: ApolloClient, options: ApolloClient.Options): void; + v: 1; + } } /** @@ -708,6 +722,7 @@ export class ApolloClient { dataMasking, link, incrementalHandler = new NotImplementedHandler(), + experiments = [], } = options; this.link = link; @@ -759,6 +774,8 @@ export class ApolloClient { } if (this.devtoolsConfig.enabled) this.connectToDevTools(); + + experiments.forEach((experiment) => experiment.call(this, options)); } private connectToDevTools() { diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 37218418bf5..2a16988ac9b 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -8,13 +8,16 @@ import type { Subscribable, Subscription, } from "rxjs"; -import { BehaviorSubject, Observable, share, Subject, tap } from "rxjs"; +import { BehaviorSubject, filter, Observable, share, Subject, tap } from "rxjs"; import type { Cache, MissingFieldError } from "@apollo/client/cache"; import type { MissingTree } from "@apollo/client/cache"; import type { MaybeMasked, Unmasked } from "@apollo/client/masking"; import type { DeepPartial } from "@apollo/client/utilities"; -import { isNetworkRequestInFlight } from "@apollo/client/utilities"; +import { + isNetworkRequestInFlight, + isNetworkRequestSettled, +} from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import { compact, @@ -353,6 +356,10 @@ export class ObservableQuery< return this.subject.getValue().result.networkStatus; } + private get cache() { + return this.queryManager.cache; + } + constructor({ queryManager, options, @@ -556,7 +563,7 @@ export class ObservableQuery< * @internal */ public getCacheDiff({ optimistic = true } = {}) { - return this.queryManager.cache.diff({ + return this.cache.diff({ query: this.query, variables: this.variables, returnPartialData: true, @@ -691,7 +698,7 @@ export class ObservableQuery< } }, }; - const cancelWatch = this.queryManager.cache.watch(watch); + const cancelWatch = this.cache.watch(watch); this.unsubscribeFromCache = Object.assign( () => { @@ -806,6 +813,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, TFetchVars > ): Promise>; + public fetchMore< TFetchData = TData, TFetchVars extends OperationVariables = TVariables, @@ -864,7 +872,6 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, : combinedOptions.query; let wasUpdated = false; - const isCached = this.options.fetchPolicy !== "no-cache"; if (!isCached) { @@ -877,6 +884,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const { finalize, pushNotification } = this.pushOperation( NetworkStatus.fetchMore ); + pushNotification( { source: "newNetworkStatus", @@ -885,115 +893,190 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, }, { shouldEmit: EmitBehavior.networkStatusChange } ); - return this.queryManager - .fetchQuery(combinedOptions, NetworkStatus.fetchMore) - .then((fetchMoreResult) => { - // disable the `fetchMore` override that is currently active - // the next updates caused by this should not be `fetchMore` anymore, - // but `ready` or whatever other calculated loading state is currently - // appropriate - finalize(); - if (isCached) { - // Performing this cache update inside a cache.batch transaction ensures - // any affected cache.watch watchers are notified at most once about any - // updates. Most watchers will be using the QueryInfo class, which - // responds to notifications by calling reobserveCacheFirst to deliver - // fetchMore cache results back to this ObservableQuery. - this.queryManager.cache.batch({ - update: (cache) => { - if (updateQuery) { - cache.updateQuery( - { - query: this.query, - variables: this.variables, - returnPartialData: true, - optimistic: false, - }, - (previous) => - updateQuery(previous! as any, { - fetchMoreResult: fetchMoreResult.data as any, - variables: combinedOptions.variables as TFetchVars, - }) - ); - } else { - // If we're using a field policy instead of updateQuery, the only - // thing we need to do is write the new data to the cache using - // combinedOptions.variables (instead of this.variables, which is - // what this.updateQuery uses, because it works by abusing the - // original field value, keyed by the original variables). - cache.writeQuery({ - query: combinedOptions.query, - variables: combinedOptions.variables, - data: fetchMoreResult.data as Unmasked, - }); - } - }, - - onWatchUpdated: (watch) => { - if (watch.watcher === this) { - wasUpdated = true; - } - }, - }); - } else { - // There is a possibility `lastResult` may not be set when - // `fetchMore` is called which would cause this to crash. This should - // only happen if we haven't previously reported a result. We don't - // quite know what the right behavior should be here since this block - // of code runs after the fetch result has executed on the network. - // We plan to let it crash in the meantime. - // - // If we get bug reports due to the `data` property access on - // undefined, this should give us a real-world scenario that we can - // use to test against and determine the right behavior. If we do end - // up changing this behavior, this may require, for example, an - // adjustment to the types on `updateQuery` since that function - // expects that the first argument always contains previous result - // data, but not `undefined`. - const lastResult = this.getCurrentResult(); - const data = updateQuery!(lastResult.data as Unmasked, { - fetchMoreResult: fetchMoreResult.data as Unmasked, - variables: combinedOptions.variables as TFetchVars, - }); - // was reportResult - pushNotification({ - kind: "N", - value: { - ...lastResult, - networkStatus: NetworkStatus.ready, - // will be overwritten anyways, just here for types sake - loading: false, - data: data as any, - dataState: - lastResult.dataState === "streaming" ? "streaming" : "complete", - }, - source: "network", - }); + const { promise, operator } = getTrackingOperatorPromise( + (value: QueryNotification.Value) => { + switch (value.kind) { + case "E": { + throw value.error; + } + case "N": { + if (value.source !== "newNetworkStatus" && !value.value.loading) { + return value.value; + } + } } + } + ); - return this.maskResult(fetchMoreResult); - }) - .finally(() => { - // call `finalize` a second time in case the `.then` case above was not reached - finalize(); + const { observable } = this.queryManager.fetchObservableWithInfo( + combinedOptions, + { networkStatus: NetworkStatus.fetchMore } + ); - // In case the cache writes above did not generate a broadcast - // notification (which would have been intercepted by onWatchUpdated), - // likely because the written data were the same as what was already in - // the cache, we still want fetchMore to deliver its final loading:false - // result with the unchanged data. - if (isCached && !wasUpdated) { - pushNotification( - { + const subscription = observable + .pipe( + operator, + filter( + ( + notification + ): notification is Extract< + QueryNotification.FromNetwork, + { kind: "N" } + > => notification.kind === "N" && notification.source === "network" + ) + ) + .subscribe({ + next: (notification) => { + wasUpdated = false; + const fetchMoreResult = notification.value; + + if (isNetworkRequestSettled(notification.value.networkStatus)) { + finalize(); + } + + if (isCached) { + // Performing this cache update inside a cache.batch transaction ensures + // any affected cache.watch watchers are notified at most once about any + // updates. Most watchers will be using the QueryInfo class, which + // responds to notifications by calling reobserveCacheFirst to deliver + // fetchMore cache results back to this ObservableQuery. + this.cache.batch({ + update: (cache) => { + if (updateQuery) { + cache.updateQuery( + { + query: this.query, + variables: this.variables, + returnPartialData: true, + optimistic: false, + }, + (previous) => + updateQuery(previous! as any, { + fetchMoreResult: fetchMoreResult.data as any, + variables: combinedOptions.variables as TFetchVars, + }) + ); + } else { + // If we're using a field policy instead of updateQuery, the only + // thing we need to do is write the new data to the cache using + // combinedOptions.variables (instead of this.variables, which is + // what this.updateQuery uses, because it works by abusing the + // original field value, keyed by the original variables). + cache.writeQuery({ + query: combinedOptions.query, + variables: combinedOptions.variables, + data: fetchMoreResult.data as Unmasked, + }); + } + }, + + onWatchUpdated: (watch, diff) => { + if (watch.watcher === this) { + wasUpdated = true; + const lastResult = this.getCurrentResult(); + + // Let the cache watch from resubscribeCache handle the final + // result + if (isNetworkRequestInFlight(fetchMoreResult.networkStatus)) { + pushNotification({ + kind: "N", + source: "network", + value: { + ...lastResult, + networkStatus: + ( + fetchMoreResult.networkStatus === + NetworkStatus.error + ) ? + NetworkStatus.ready + : fetchMoreResult.networkStatus, + // will be overwritten anyways, just here for types sake + loading: false, + data: diff.result, + dataState: + fetchMoreResult.dataState === "streaming" ? + "streaming" + : "complete", + }, + }); + } + } + }, + }); + } else { + // There is a possibility `lastResult` may not be set when + // `fetchMore` is called which would cause this to crash. This should + // only happen if we haven't previously reported a result. We don't + // quite know what the right behavior should be here since this block + // of code runs after the fetch result has executed on the network. + // We plan to let it crash in the meantime. + // + // If we get bug reports due to the `data` property access on + // undefined, this should give us a real-world scenario that we can + // use to test against and determine the right behavior. If we do end + // up changing this behavior, this may require, for example, an + // adjustment to the types on `updateQuery` since that function + // expects that the first argument always contains previous result + // data, but not `undefined`. + const lastResult = this.getCurrentResult(); + const data = updateQuery!(lastResult.data as Unmasked, { + fetchMoreResult: fetchMoreResult.data as Unmasked, + variables: combinedOptions.variables as TFetchVars, + }); + + pushNotification({ kind: "N", - source: "newNetworkStatus", - value: {}, - }, - { shouldEmit: EmitBehavior.force } - ); - } + value: { + ...lastResult, + networkStatus: NetworkStatus.ready, + // will be overwritten anyways, just here for types sake + loading: false, + data: data as any, + dataState: + lastResult.dataState === "streaming" ? + "streaming" + : "complete", + }, + source: "network", + }); + } + }, }); + + return preventUnhandledRejection( + promise + .then((result) => toQueryResult(this.maskResult(result))) + .finally(() => { + subscription.unsubscribe(); + if (isCached && !wasUpdated) { + finalize(); + + const lastResult = this.getCurrentResult(); + + if (lastResult.networkStatus === NetworkStatus.streaming) { + pushNotification({ + kind: "N", + source: "network", + value: { + ...lastResult, + dataState: "complete", + networkStatus: NetworkStatus.ready, + } as any, + }); + } else { + pushNotification( + { + kind: "N", + source: "newNetworkStatus", + value: {}, + }, + { shouldEmit: EmitBehavior.force } + ); + } + } + }) + ); } // XXX the subscription variables are separate from the query variables. @@ -1131,7 +1214,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, ); if (newResult) { - queryManager.cache.writeQuery({ + this.cache.writeQuery({ query: this.options.query, data: newResult, variables: this.variables, @@ -1667,8 +1750,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, if ( dirty && - (this.options.fetchPolicy == "cache-only" || - this.options.fetchPolicy == "cache-and-network" || + (this.options.fetchPolicy === "cache-only" || + this.options.fetchPolicy === "cache-and-network" || !this.activeOperations.size) ) { const diff = this.getCacheDiff(); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 7b239329eb4..83a2bcccc39 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -331,6 +331,7 @@ export class QueryManager { optimisticResponse: isOptimistic ? optimisticResponse : void 0, }, variables, + fetchPolicy, {}, false ) @@ -748,7 +749,7 @@ export class QueryManager { ): SubscriptionObservable> { let { query, variables } = options; const { - fetchPolicy, + fetchPolicy = "cache-first", errorPolicy = "none", context = {}, extensions = {}, @@ -785,6 +786,7 @@ export class QueryManager { query, context, variables, + fetchPolicy, extensions ); @@ -864,7 +866,8 @@ export class QueryManager { private getObservableFromLink( query: DocumentNode, context: DefaultContext | undefined, - variables?: OperationVariables, + variables: OperationVariables, + fetchPolicy: WatchQueryFetchPolicy, extensions?: Record, // Prefer context.queryDeduplication if specified. deduplication: boolean = context?.queryDeduplication ?? @@ -994,6 +997,7 @@ export class QueryManager { remoteResult: result as FormattedExecutionResult, context, variables, + fetchPolicy, }) ); }) @@ -1041,7 +1045,8 @@ export class QueryManager { return this.getObservableFromLink( linkDocument, options.context, - options.variables + options.variables, + options.fetchPolicy ).observable.pipe( map((incoming) => { // Use linkDocument rather than queryInfo.document so the @@ -1602,6 +1607,7 @@ export class QueryManager { variables, onlyRunForcedResolvers: true, returnPartialData: true, + fetchPolicy, }).then( (resolved): QueryNotification.FromCache => ({ kind: "N", diff --git a/src/core/__tests__/ApolloClient/general.test.ts b/src/core/__tests__/ApolloClient/general.test.ts index eccc8b5245d..588141c8e8a 100644 --- a/src/core/__tests__/ApolloClient/general.test.ts +++ b/src/core/__tests__/ApolloClient/general.test.ts @@ -9,12 +9,10 @@ import type { ObservableQuery, TypedDocumentNode } from "@apollo/client"; import { ApolloClient, NetworkStatus } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { CombinedGraphQLErrors } from "@apollo/client/errors"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { ClientAwarenessLink } from "@apollo/client/link/client-awareness"; import { MockLink } from "@apollo/client/testing"; import { - mockDeferStream, ObservableStream, spyOnConsole, wait, @@ -7548,160 +7546,6 @@ describe("ApolloClient", () => { ) ).toBeUndefined(); }); - - it("deduplicates queries as long as a query still has deferred chunks", async () => { - const query = gql` - query LazyLoadLuke { - people(id: 1) { - id - name - friends { - id - ... @defer { - name - } - } - } - } - `; - - const outgoingRequestSpy = jest.fn(((operation, forward) => - forward(operation)) satisfies ApolloLink.RequestHandler); - const defer = mockDeferStream(); - const client = new ApolloClient({ - cache: new InMemoryCache({}), - link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), - incrementalHandler: new Defer20220824Handler(), - }); - - const query1 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - const query2 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); - - const initialData = { - people: { - __typename: "Person", - id: 1, - name: "Luke", - friends: [ - { - __typename: "Person", - id: 5, - } as { __typename: "Person"; id: number; name?: string }, - { - __typename: "Person", - id: 8, - } as { __typename: "Person"; id: number; name?: string }, - ], - }, - }; - const initialResult: ObservableQuery.Result = { - data: initialData, - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - partial: true, - }; - - defer.enqueueInitialChunk({ - data: initialData, - hasNext: true, - }); - - await expect(query1).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - await expect(query2).toEmitTypedValue({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - partial: true, - }); - - await expect(query1).toEmitTypedValue(initialResult); - await expect(query2).toEmitTypedValue(initialResult); - - const query3 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - await expect(query3).toEmitTypedValue(initialResult); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); - - const firstChunk = { - incremental: [ - { - data: { - name: "Leia", - }, - path: ["people", "friends", 0], - }, - ], - hasNext: true, - }; - const resultAfterFirstChunk = structuredClone( - initialResult - ) as ObservableQuery.Result; - resultAfterFirstChunk.data.people.friends[0].name = "Leia"; - - defer.enqueueSubsequentChunk(firstChunk); - - await expect(query1).toEmitTypedValue(resultAfterFirstChunk); - await expect(query2).toEmitTypedValue(resultAfterFirstChunk); - await expect(query3).toEmitTypedValue(resultAfterFirstChunk); - - const query4 = new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - await expect(query4).toEmitTypedValue(resultAfterFirstChunk); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); - - const secondChunk = { - incremental: [ - { - data: { - name: "Han Solo", - }, - path: ["people", "friends", 1], - }, - ], - hasNext: false, - }; - const resultAfterSecondChunk = { - ...structuredClone(resultAfterFirstChunk), - loading: false, - networkStatus: NetworkStatus.ready, - dataState: "complete", - partial: false, - } as ObservableQuery.Result; - resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; - - defer.enqueueSubsequentChunk(secondChunk); - - await expect(query1).toEmitTypedValue(resultAfterSecondChunk); - await expect(query2).toEmitTypedValue(resultAfterSecondChunk); - await expect(query3).toEmitTypedValue(resultAfterSecondChunk); - await expect(query4).toEmitTypedValue(resultAfterSecondChunk); - - // TODO: Re-enable once below condition can be met - /* const query5 = */ new ObservableStream( - client.watchQuery({ query, fetchPolicy: "network-only" }) - ); - // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we - // get the loading state. This test fails with the switch to RxJS for now - // since the initial value is emitted synchronously unlike zen-observable - // where the emitted result wasn't emitted until after this assertion. - // expect(query5).not.toEmitAnything(); - expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); - }); }); describe("missing cache field warnings", () => { diff --git a/src/core/__tests__/ApolloClient/multiple-results.test.ts b/src/core/__tests__/ApolloClient/multiple-results.test.ts index 466e02c920e..1706bb859d7 100644 --- a/src/core/__tests__/ApolloClient/multiple-results.test.ts +++ b/src/core/__tests__/ApolloClient/multiple-results.test.ts @@ -13,7 +13,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -29,7 +29,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -82,7 +81,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -98,7 +97,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -165,7 +163,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -181,7 +179,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -241,7 +238,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } @@ -257,7 +254,6 @@ describe("mutiple results", () => { const laterData = { people_one: { - // XXX true defer's wouldn't send this name: "Luke Skywalker", friends: [{ name: "Leia Skywalker" }], }, @@ -317,7 +313,7 @@ describe("mutiple results", () => { query LazyLoadLuke { people_one(id: 1) { name - friends @defer { + friends { name } } diff --git a/src/core/__tests__/client.watchQuery/defer20220824.test.ts b/src/core/__tests__/client.watchQuery/defer20220824.test.ts new file mode 100644 index 00000000000..d8934c96ea5 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/defer20220824.test.ts @@ -0,0 +1,349 @@ +import { gql } from "graphql-tag"; + +import type { ObservableQuery } from "@apollo/client"; +import { ApolloClient, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { ApolloLink } from "@apollo/client/link"; +import { + markAsStreaming, + mockDefer20220824, + ObservableStream, +} from "@apollo/client/testing/internal"; + +test("deduplicates queries as long as a query still has deferred chunks", async () => { + const query = gql` + query LazyLoadLuke { + people(id: 1) { + id + name + friends { + id + ... @defer { + name + } + } + } + } + `; + + const outgoingRequestSpy = jest.fn(((operation, forward) => + forward(operation)) satisfies ApolloLink.RequestHandler); + const defer = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), + incrementalHandler: new Defer20220824Handler(), + }); + + const query1 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + const query2 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const initialData = { + people: { + __typename: "Person", + id: 1, + name: "Luke", + friends: [ + { + __typename: "Person", + id: 5, + } as { __typename: "Person"; id: number; name?: string }, + { + __typename: "Person", + id: 8, + } as { __typename: "Person"; id: number; name?: string }, + ], + }, + }; + const initialResult: ObservableQuery.Result = { + data: initialData, + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }; + + defer.enqueueInitialChunk({ + data: initialData, + hasNext: true, + }); + + await expect(query1).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(query2).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(query1).toEmitTypedValue(initialResult); + await expect(query2).toEmitTypedValue(initialResult); + + const query3 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query3).toEmitTypedValue(initialResult); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const firstChunk = { + incremental: [ + { + data: { + name: "Leia", + }, + path: ["people", "friends", 0], + }, + ], + hasNext: true, + }; + const resultAfterFirstChunk = structuredClone( + initialResult + ) as ObservableQuery.Result; + resultAfterFirstChunk.data.people.friends[0].name = "Leia"; + + defer.enqueueSubsequentChunk(firstChunk); + + await expect(query1).toEmitTypedValue(resultAfterFirstChunk); + await expect(query2).toEmitTypedValue(resultAfterFirstChunk); + await expect(query3).toEmitTypedValue(resultAfterFirstChunk); + + const query4 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query4).toEmitTypedValue(resultAfterFirstChunk); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const secondChunk = { + incremental: [ + { + data: { + name: "Han Solo", + }, + path: ["people", "friends", 1], + }, + ], + hasNext: false, + }; + const resultAfterSecondChunk = { + ...structuredClone(resultAfterFirstChunk), + loading: false, + networkStatus: NetworkStatus.ready, + dataState: "complete", + partial: false, + } as ObservableQuery.Result; + resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; + + defer.enqueueSubsequentChunk(secondChunk); + + await expect(query1).toEmitTypedValue(resultAfterSecondChunk); + await expect(query2).toEmitTypedValue(resultAfterSecondChunk); + await expect(query3).toEmitTypedValue(resultAfterSecondChunk); + await expect(query4).toEmitTypedValue(resultAfterSecondChunk); + + // TODO: Re-enable once below condition can be met + /* const query5 = */ new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we + // get the loading state. This test fails with the switch to RxJS for now + // since the initial value is emitted synchronously unlike zen-observable + // where the emitted result wasn't emitted until after this assertion. + // expect(query5).not.toEmitAnything(); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); +}); + +it.each([["cache-first"], ["no-cache"]] as const)( + "correctly merges deleted rows when receiving a deferred payload", + async (fetchPolicy) => { + const query = gql` + query Characters { + characters { + id + uppercase + ... @defer { + lowercase + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + const observable = client.watchQuery({ query, fetchPolicy }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [{ data: { lowercase: "a" }, path: ["characters", 0] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "b" }, path: ["characters", 1] }, + { data: { lowercase: "c" }, path: ["characters", 2] }, + ], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + void observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + // on refetch, the list is shorter + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ], + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: + // no-cache fetch policy doesn't merge with existing cache data, so + // the lowercase field is not added to each item + fetchPolicy === "no-cache" ? + [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ] + : [ + { + __typename: "Character", + id: 1, + uppercase: "A", + lowercase: "a", + }, + { + __typename: "Character", + id: 2, + uppercase: "B", + lowercase: "b", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "a" }, path: ["characters", 0] }, + { data: { lowercase: "b" }, path: ["characters", 1] }, + ], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + } +); diff --git a/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts new file mode 100644 index 00000000000..5464258a417 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts @@ -0,0 +1,371 @@ +import { gql } from "graphql-tag"; + +import type { ObservableQuery } from "@apollo/client"; +import { ApolloClient, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { ApolloLink } from "@apollo/client/link"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, +} from "@apollo/client/testing/internal"; + +test("deduplicates queries as long as a query still has deferred chunks", async () => { + const query = gql` + query LazyLoadLuke { + people(id: 1) { + id + name + friends { + id + ... @defer { + name + } + } + } + } + `; + + const outgoingRequestSpy = jest.fn(((operation, forward) => + forward(operation)) satisfies ApolloLink.RequestHandler); + const defer = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: new ApolloLink(outgoingRequestSpy).concat(defer.httpLink), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query1 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + const query2 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const initialData = { + people: { + __typename: "Person", + id: 1, + name: "Luke", + friends: [ + { + __typename: "Person", + id: 5, + } as { __typename: "Person"; id: number; name?: string }, + { + __typename: "Person", + id: 8, + } as { __typename: "Person"; id: number; name?: string }, + ], + }, + }; + const initialResult: ObservableQuery.Result = { + data: initialData, + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }; + + defer.enqueueInitialChunk({ + data: initialData, + pending: [ + { id: "0", path: ["people", "friends", 0] }, + { id: "1", path: ["people", "friends", 1] }, + ], + hasNext: true, + }); + + await expect(query1).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(query2).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(query1).toEmitTypedValue(initialResult); + await expect(query2).toEmitTypedValue(initialResult); + + const query3 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query3).toEmitTypedValue(initialResult); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const firstChunk: GraphQL17Alpha9Handler.SubsequentResult< + Record + > = { + incremental: [ + { + data: { + name: "Leia", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }; + const resultAfterFirstChunk = structuredClone( + initialResult + ) as ObservableQuery.Result; + resultAfterFirstChunk.data.people.friends[0].name = "Leia"; + + defer.enqueueSubsequentChunk(firstChunk); + + await expect(query1).toEmitTypedValue(resultAfterFirstChunk); + await expect(query2).toEmitTypedValue(resultAfterFirstChunk); + await expect(query3).toEmitTypedValue(resultAfterFirstChunk); + + const query4 = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + await expect(query4).toEmitTypedValue(resultAfterFirstChunk); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(1); + + const secondChunk: GraphQL17Alpha9Handler.SubsequentResult< + Record + > = { + incremental: [ + { + data: { + name: "Han Solo", + }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }; + const resultAfterSecondChunk = { + ...structuredClone(resultAfterFirstChunk), + loading: false, + networkStatus: NetworkStatus.ready, + dataState: "complete", + partial: false, + } as ObservableQuery.Result; + resultAfterSecondChunk.data.people.friends[1].name = "Han Solo"; + + defer.enqueueSubsequentChunk(secondChunk); + + await expect(query1).toEmitTypedValue(resultAfterSecondChunk); + await expect(query2).toEmitTypedValue(resultAfterSecondChunk); + await expect(query3).toEmitTypedValue(resultAfterSecondChunk); + await expect(query4).toEmitTypedValue(resultAfterSecondChunk); + + // TODO: Re-enable once below condition can be met + /* const query5 = */ new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + // TODO: Re-enable once notifyOnNetworkStatusChange controls whether we + // get the loading state. This test fails with the switch to RxJS for now + // since the initial value is emitted synchronously unlike zen-observable + // where the emitted result wasn't emitted until after this assertion. + // expect(query5).not.toEmitAnything(); + expect(outgoingRequestSpy).toHaveBeenCalledTimes(2); +}); + +it.each([["cache-first"], ["no-cache"]] as const)( + "correctly merges deleted rows when receiving a deferred payload", + async (fetchPolicy) => { + const query = gql` + query Characters { + characters { + id + uppercase + ... @defer { + lowercase + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const observable = client.watchQuery({ query, fetchPolicy }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }, + pending: [ + { id: "0", path: ["characters", 0] }, + { id: "1", path: ["characters", 1] }, + { id: "2", path: ["characters", 2] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [{ data: { lowercase: "a" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B" }, + { __typename: "Character", id: 3, uppercase: "C" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "b" }, id: "1" }, + { data: { lowercase: "c" }, id: "2" }, + ], + completed: [{ id: "1" }, { id: "2" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + void observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + { __typename: "Character", id: 3, uppercase: "C", lowercase: "c" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + // on refetch, the list is shorter + enqueueInitialChunk({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ], + }, + pending: [ + { id: "0", path: ["characters", 0] }, + { id: "1", path: ["characters", 1] }, + ], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + characters: + // no-cache fetch policy doesn't merge with existing cache data, so + // the lowercase field is not available in the refetch + fetchPolicy === "no-cache" ? + [ + { __typename: "Character", id: 1, uppercase: "A" }, + { __typename: "Character", id: 2, uppercase: "B" }, + ] + : [ + { + __typename: "Character", + id: 1, + uppercase: "A", + lowercase: "a", + }, + { + __typename: "Character", + id: 2, + uppercase: "B", + lowercase: "b", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + enqueueSubsequentChunk({ + incremental: [ + { data: { lowercase: "a" }, id: "0" }, + { data: { lowercase: "b" }, id: "1" }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + characters: [ + { __typename: "Character", id: 1, uppercase: "A", lowercase: "a" }, + { __typename: "Character", id: 2, uppercase: "B", lowercase: "b" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + } +); diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts new file mode 100644 index 00000000000..8216613d93a --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -0,0 +1,989 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + mockDefer20220824, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; +import { hasDirectives } from "@apollo/client/utilities/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDefer20220824(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [{ __typename: "Friend", id: "2", name: "Han" }] as any, + path: ["friendList", 1], + }, + { + items: [{ __typename: "Friend", id: "3", name: "Leia" }] as any, + path: ["friendList", 2], + }, + ], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +// TODO: Determine how to handle this case. This emits an error for the item at +// index 1 because it is non-null, but also emits the friend at index 2 to add +// to the array. This leaves us in a bit of an impossible state as +// we can't really set nonNullFriendList[1] to `null`, otherwise we violate the +// schema. Should we stop processing results if we recieve an `items: null` from +// the server indicating an error was thrown to the nearest boundary? +it.failing( + "handles errors thrown due to null returned in non-null list items after initialCount is reached", + async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); + } +); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(null); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("can use custom merge function to combine cached and streamed lists", async () => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (existing = [], incoming, { field }) => { + if (field && hasDirectives(["stream"], field)) { + const merged: any[] = []; + + for ( + let i = 0; + i < Math.max(existing.length, incoming.length); + i++ + ) { + merged[i] = + incoming[i] === undefined ? existing[i] : incoming[i]; + } + + return merged; + } + + return incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => friends.map((friend) => Promise.resolve(friend)), + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts new file mode 100644 index 00000000000..fe33f498d34 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -0,0 +1,1007 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; +import { hasDirectives } from "@apollo/client/utilities/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + pending: [{ id: "0", path: ["friendList"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: null, + }, + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { friendList: [{ __typename: "Friend", id: "1", name: "Luke" }] }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles errors thrown due to null returned in non-null list items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("can use custom merge function to combine cached and streamed lists", async () => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (existing = [], incoming, { field }) => { + if (field && hasDirectives(["stream"], field)) { + const merged: any[] = []; + + for ( + let i = 0; + i < Math.max(existing.length, incoming.length); + i++ + ) { + merged[i] = + incoming[i] === undefined ? existing[i] : incoming[i]; + } + + return merged; + } + + return incoming; + }, + }, + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => friends.map((friend) => Promise.resolve(friend)), + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + const stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts similarity index 91% rename from src/incremental/handlers/__tests__/defer20220824.test.ts rename to src/incremental/handlers/__tests__/defer20220824/defer.test.ts index f5795710d6b..7ed32d8c991 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -1,13 +1,6 @@ import assert from "node:assert"; -import type { - DocumentNode, - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha2"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -15,7 +8,9 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha2"; +import { from } from "rxjs"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -23,19 +18,19 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, - mockDeferStream, + mockDefer20220824, ObservableStream, } from "@apollo/client/testing/internal"; import { hasIncrementalChunks, // eslint-disable-next-line local-rules/no-relative-imports -} from "../defer20220824.js"; +} from "../../defer20220824.js"; // This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: // https://github.com/graphql/graphql-js/blob/364cd71d1a26eb6f62661efd7fa399e91332d30d/src/execution/__tests__/defer-test.ts @@ -105,41 +100,12 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -async function* run( - document: DocumentNode -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult, - FormattedExecutionResult | void -> { - const result = await experimentalExecuteIncrementally({ - schema, - document, - rootValue: {}, - }); - if ("initialResult" in result) { - yield JSON.parse( - JSON.stringify(result.initialResult) - ) as FormattedInitialIncrementalExecutionResult; - for await (const incremental of result.subsequentResults) { - yield JSON.parse( - JSON.stringify(incremental) - ) as FormattedSubsequentIncrementalExecutionResult; - } - } else { - return result; - } +function run(query: DocumentNode) { + return executeSchemaGraphQL17Alpha2(schema, query); } const schemaLink = new ApolloLink((operation) => { - return new Observable((observer) => { - void (async () => { - for await (const chunk of run(operation.query)) { - observer.next(chunk); - } - observer.complete(); - })(); - }); + return from(run(operation.query)); }); describe("graphql-js test cases", () => { @@ -166,7 +132,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -180,7 +146,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -234,7 +200,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -244,7 +210,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -275,7 +241,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -285,7 +251,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -329,7 +295,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -344,7 +310,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -380,7 +346,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -393,7 +359,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -433,7 +399,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -443,7 +409,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -482,7 +448,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -492,7 +458,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -563,7 +529,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -573,7 +539,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -589,7 +555,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -683,7 +649,7 @@ test("Defer20220824Handler can be used with `ApolloClient`", async () => { }); test("merges cache updates that happen concurrently", async () => { - const stream = mockDeferStream(); + const stream = mockDefer20220824(); const client = new ApolloClient({ link: stream.httpLink, cache: new InMemoryCache(), @@ -979,7 +945,7 @@ test("stream that returns an error but continues to stream", async () => { }); test("handles final chunk of { hasNext: false } correctly in usage with Apollo Client", async () => { - const stream = mockDeferStream(); + const stream = mockDefer20220824(); const client = new ApolloClient({ link: stream.httpLink, cache: new InMemoryCache(), diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts new file mode 100644 index 00000000000..3f2cd4e4fed --- /dev/null +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -0,0 +1,1977 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: +// https://github.com/graphql/graphql-js/blob/042002c3d332d36c67861f5b37d39b74d54d97d4/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function run(document: DocumentNode, rootValue: unknown = {}) { + return executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + document, + rootValue + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("Execute: stream directive", () => { + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client persective, a regular graphql query + }); + + it.skip("Can handle concurrent calls to .next() without waiting", async () => { + // from a client persective, a repeat of a previous test + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the previous test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { + scalarField: null, + }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Can @defer fields that are resolved before async iterable is complete", async () => { + // from a client perspective, a repeat of the previous test + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +test("Defer20220824Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }] }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle({ friendList: [{ id: "1", name: "Luke Cached" }] }, chunk) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts new file mode 100644 index 00000000000..885a428f267 --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -0,0 +1,2631 @@ +import assert from "node:assert"; + +import { + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + promiseWithResolvers, + wait, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: +// https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/defer-test.ts + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, + { name: "C-3PO", id: 4 }, +]; + +const deeperObject = new GraphQLObjectType({ + fields: { + foo: { type: GraphQLString }, + bar: { type: GraphQLString }, + baz: { type: GraphQLString }, + bak: { type: GraphQLString }, + }, + name: "DeeperObject", +}); + +const nestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + name: { type: GraphQLString }, + }, + name: "NestedObject", +}); + +const anotherNestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + }, + name: "AnotherNestedObject", +}); + +const hero = { + name: "Luke", + id: 1, + friends, + nestedObject, + anotherNestedObject, +}; + +const c = new GraphQLObjectType({ + fields: { + d: { type: GraphQLString }, + nonNullErrorField: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "c", +}); + +const e = new GraphQLObjectType({ + fields: { + f: { type: GraphQLString }, + }, + name: "e", +}); + +const b = new GraphQLObjectType({ + fields: { + c: { type: c }, + e: { type: e }, + }, + name: "b", +}); + +const a = new GraphQLObjectType({ + fields: { + b: { type: b }, + someField: { type: GraphQLString }, + }, + name: "a", +}); + +const g = new GraphQLObjectType({ + fields: { + h: { type: GraphQLString }, + }, + name: "g", +}); + +const heroType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + friends: { + type: new GraphQLList(friendType), + }, + nestedObject: { type: nestedObject }, + anotherNestedObject: { type: anotherNestedObject }, + }, + name: "Hero", +}); + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + }, + a: { type: a }, + g: { type: g }, + }, + name: "Query", +}); + +const schema = new GraphQLSchema({ query }); + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +function run( + document: DocumentNode, + rootValue: unknown = { hero }, + enableEarlyExecution?: boolean +) { + return executeSchemaGraphQL17Alpha9( + schema, + document, + rootValue, + enableEarlyExecution + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("graphql-js test cases", () => { + // These test cases mirror defer tests of the `graphql-js` v17.0.0-alpha.9 release: + // https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/defer-test.ts + + it("Can defer fragments containing scalar types", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can disable defer using if argument", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(if: false) + } + } + fragment NameFragment on Hero { + name + } + `; + const handler = new GraphQL17Alpha9Handler(); + const incoming = run(query); + + const { value: chunk } = await incoming.next(); + + assert(chunk); + expect(handler.isIncrementalResult(chunk)).toBe(false); + }); + + it.skip("Does not disable defer with null if argument", async () => { + // test is not interesting from a client perspective + }); + + it.skip("Does not execute deferred fragments early when not specified", async () => { + // test is not interesting from a client perspective + }); + + it.skip("Does execute deferred fragments early when specified", async () => { + // test is not interesting from a client perspective + }); + + it("Can defer fragments on the top level Query field", async () => { + const query = gql` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can defer fragments with errors on the top level Query field", async () => { + const query = gql` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + name + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: () => { + throw new Error("bad"); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + name: null, + }, + }, + errors: [ + { + message: "bad", + path: ["hero", "name"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can defer a fragment within an already deferred fragment", async () => { + const query = gql` + query HeroNameQuery { + hero { + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + id + ...NestedFragment @defer(label: "DeferNested") + } + fragment NestedFragment on Hero { + friends { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Can defer a fragment that is also not deferred, deferred fragment is first", async () => { + // from the client perspective, a regular graphql query + }); + + it.skip("Can defer a fragment that is also not deferred, non-deferred fragment is first", async () => { + // from the client perspective, a regular graphql query + }); + + it("Can defer an inline fragment", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ... on Hero @defer(label: "InlineDeferred") { + name + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Does not emit empty defer fragments", async () => { + // from the client perspective, a regular query + }); + + it("Emits children of empty defer fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer { + ... @defer { + name + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can separately emit defer fragments with different labels with varying fields", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + ... @defer(label: "DeferName") { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Separately emits defer fragments with different labels with varying subfields", async () => { + const query = gql` + query HeroNameQuery { + ... @defer(label: "DeferID") { + hero { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Separately emits defer fragments with different labels with varying subfields that return promises", async () => { + // from the client perspective, a repeat of the last one + }); + + it("Separately emits defer fragments with varying subfields of same priorities but different level of defers", async () => { + const query = gql` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Separately emits nested defer fragments with varying subfields of same priorities but different level of defers", async () => { + const query = gql` + query HeroNameQuery { + ... @defer(label: "DeferName") { + hero { + name + ... @defer(label: "DeferID") { + id + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Initiates deferred grouped field sets only if they have been released as pending", async () => { + const query = gql` + query { + ... @defer { + a { + ... @defer { + b { + c { + d + } + } + } + } + } + ... @defer { + a { + someField + ... @defer { + b { + e { + f + } + } + } + } + } + } + `; + + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + someField: slowFieldPromise, + b: { + c: () => { + return { d: "d" }; + }, + e: () => { + return { f: "f" }; + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("someField"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + someField: "someField", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Initiates unique deferred grouped field sets after those that are common to sibling defers", async () => { + const query = gql` + query { + ... @defer { + a { + ... @defer { + b { + c { + d + } + } + } + } + } + ... @defer { + a { + ... @defer { + b { + c { + d + } + e { + f + } + } + } + } + } + } + `; + + const { promise: cPromise, resolve: resolveC } = + promiseWithResolvers(); + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { + c: async () => { + await cPromise; + return { d: "d" }; + }, + e: () => { + return { f: "f" }; + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveC(); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can deduplicate multiple defers on the same object", async () => { + const query = gql` + query { + hero { + friends { + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + } + } + } + } + } + } + } + + fragment FriendFrag on Friend { + id + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [{}, {}, {}], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [ + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + { id: "4", name: "C-3PO" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields present in the initial payload", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + anotherNestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + bar + } + } + anotherNestedObject { + deeperObject { + foo + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + nestedObject: { deeperObject: { foo: "foo", bar: "bar" } }, + anotherNestedObject: { deeperObject: { foo: "foo" } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + }, + }, + anotherNestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + anotherNestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields present in a parent defer payload", async () => { + const query = gql` + query { + hero { + ... @defer { + nestedObject { + deeperObject { + foo + ... @defer { + foo + bar + } + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { nestedObject: { deeperObject: { foo: "foo", bar: "bar" } } }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates fields with deferred fragments at multiple levels", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + foo + bar + } + ... @defer { + deeperObject { + foo + bar + baz + ... @defer { + foo + bar + baz + bak + } + } + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + nestedObject: { + deeperObject: { foo: "foo", bar: "bar", baz: "baz", bak: "bak" }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + baz: "baz", + bak: "bak", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicates multiple fields from deferred fragments from different branches occurring at the same level", async () => { + const query = gql` + query { + hero { + nestedObject { + deeperObject { + ... @defer { + foo + } + } + } + ... @defer { + nestedObject { + deeperObject { + ... @defer { + foo + bar + } + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { nestedObject: { deeperObject: { foo: "foo", bar: "bar" } } }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: {}, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + nestedObject: { + deeperObject: { + foo: "foo", + bar: "bar", + }, + }, + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Deduplicate fields with deferred fragments in different branches at multiple non-overlapping levels", async () => { + const query = gql` + query { + a { + b { + c { + d + } + ... @defer { + e { + f + } + } + } + } + ... @defer { + a { + b { + e { + f + } + } + } + g { + h + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + g: { h: "h" }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + e: { f: "f" }, + }, + }, + g: { + h: "h", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Correctly bundles varying subfields into incremental data records unique by defer combination, ignoring fields in a fragment masked by a parent defer", async () => { + const query = gql` + query HeroNameQuery { + ... @defer { + hero { + id + } + } + ... @defer { + hero { + name + shouldBeWithNameDespiteAdditionalDefer: name + ... @defer { + shouldBeWithNameDespiteAdditionalDefer: name + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "Luke", + shouldBeWithNameDespiteAdditionalDefer: "Luke", + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Nulls cross defer boundaries, null first", async () => { + const query = gql` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { b: { c: { d: "d" } }, someField: "someField" }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Nulls cross defer boundaries, value first", async () => { + const query = gql` + query { + ... @defer { + a { + b { + c { + d + } + } + } + } + a { + ... @defer { + someField + b { + c { + nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { d: "d" }, nonNullErrorFIeld: null }, + someField: "someField", + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles multiple erroring deferred grouped field sets", async () => { + const query = gql` + query { + ... @defer { + a { + b { + c { + someError: nonNullErrorField + } + } + } + } + ... @defer { + a { + b { + c { + anotherError: nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { nonNullErrorField: null } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "someError"], + }, + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "anotherError"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles multiple erroring deferred grouped field sets for the same fragment", async () => { + const query = gql` + query { + ... @defer { + a { + b { + someC: c { + d: d + } + anotherC: c { + d: d + } + } + } + } + ... @defer { + a { + b { + someC: c { + someError: nonNullErrorField + } + anotherC: c { + anotherError: nonNullErrorField + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + a: { + b: { c: { d: "d", nonNullErrorField: null } }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + someC: { d: "d" }, + anotherC: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "someC", "someError"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("filters a payload with a null that cannot be merged", async () => { + const query = gql` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + a: { + b: { + c: { + d: "d", + nonNullErrorField: async () => { + await resolveOnNextTick(); + return null; + }, + }, + }, + someField: "someField", + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + a: { + b: { + c: { d: "d" }, + }, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field c.nonNullErrorField.", + path: ["a", "b", "c", "nonNullErrorField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Cancels deferred fields when initial result exhibits null bubbling", async () => { + // from the client perspective, a regular graphql query + }); + + it("Cancels deferred fields when deferred result exhibits null bubbling", async () => { + const query = gql` + query { + ... @defer { + hero { + nonNullName + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + hero: { + ...hero, + nonNullName: () => null, + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: {}, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: null, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Deduplicates list fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates async iterable list fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates empty async iterable list fields", async () => { + // from the client perspective, a regular query + }); + + it("Does not deduplicate list fields with non-overlapping fields", async () => { + const query = gql` + query { + hero { + friends { + name + } + ... @defer { + friends { + id + } + } + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + friends: [ + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + { id: "4", name: "C-3PO" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Deduplicates list fields that return empty lists", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates null object fields", async () => { + // from the client perspective, a regular query + }); + + it.skip("Deduplicates promise object fields", async () => { + // from the client perspective, a regular query + }); + + it("Handles errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: () => { + throw new Error("bad"); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: null, + }, + }, + errors: [ + { + message: "bad", + path: ["hero", "name"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles non-nullable errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles non-nullable errors thrown outside deferred fragments", async () => { + // from the client perspective, a regular query + }); + + it("Handles async non-nullable errors thrown in deferred fragments", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + nonNullName: () => Promise.resolve(null), + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order", async () => { + const query = gql` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + hero: { + ...hero, + name: async () => { + await resolveOnNextTick(); + return "slow"; + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + hero: { + id: "1", + name: "slow", + friends: [{ name: "Han" }, { name: "Leia" }, { name: "C-3PO" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns payloads from synchronous data in correct order", async () => { + // from the client perspective, a repeat of the last one + }); + + it.skip("Filters deferred payloads when a list item returned by an async iterable is nulled", async () => { + // from the client perspective, a regular query + }); + + it.skip("original execute function throws error if anything is deferred and everything else is sync", () => { + // not relevant for the client + }); + + it.skip("original execute function resolves to error if anything is deferred and something else is async", async () => { + // not relevant for the client + }); +}); + +test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink(), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + job + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + hero: { + __typename: "Hero", + id: "1", + job: "Farmer", + }, + }, + pending: [{ id: "0", path: ["hero"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + job: "Farmer", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Hero:1", + fragment: gql` + fragment HeroJob on Hero { + job + } + `, + data: { + job: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + data: { + name: "Luke", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + job: "Jedi", // updated from cache + name: "Luke", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("returns error on initial result", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ + hero: { + ...hero, + nonNullName: null, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + name + } + nonNullName + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: null, + }, + error: new CombinedGraphQLErrors({ + data: { + hero: null, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("stream that returns an error but continues to stream", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ + hero: { + ...hero, + nonNullName: null, + name: async () => { + await wait(100); + return "slow"; + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query HeroNameQuery { + hero { + id + ... @defer { + nonNullName + } + ... @defer { + name + } + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + hero: { + __typename: "Hero", + id: "1", + }, + }), + error: new CombinedGraphQLErrors({ + data: { + hero: { + __typename: "Hero", + id: "1", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: false, + data: { + hero: { + __typename: "Hero", + id: "1", + name: "slow", + }, + }, + error: new CombinedGraphQLErrors({ + data: { + hero: { + __typename: "Hero", + id: "1", + name: "slow", + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Hero.nonNullName.", + path: ["hero", "nonNullName"], + }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + partial: false, + }); +}); + +test("handles final chunk of { hasNext: false } correctly in usage with Apollo Client", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ProductsQuery { + allProducts { + id + nonNullErrorField + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + stream.enqueueInitialChunk({ + data: { + allProducts: [null, null, null], + }, + pending: [], + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + hasNext: true, + }); + + stream.enqueueSubsequentChunk({ + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + allProducts: [null, null, null], + }), + error: new CombinedGraphQLErrors({ + data: { + allProducts: [null, null, null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + { + message: + "Cannot return null for non-nullable field Product.nonNullErrorField.", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }), + }); + await expect(observableStream).not.toEmitAnything(); +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts new file mode 100644 index 00000000000..bded4641629 --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -0,0 +1,2882 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: +// https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +function run( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false +) { + return executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + document, + rootValue, + enableEarlyExecution + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("graphql-js test cases", () => { + // These test cases mirror stream tests of the `graphql-js` v17.0.0-alpha.9 release: + // https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Does not execute early if not specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list with nested promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f) => ({ + name: Promise.resolve(f.name), + id: Promise.resolve(f.id), + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not execute early if not specified, when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + + const slowFriend = async (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const order: Array = []; + const slowFriend = (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can handle concurrent calls to .next() without waiting", async () => { + const query = gql(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null, friends[1]], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async generator for a non-nullable list", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable does not provide a return method) ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable provides concurrent next/return methods and has a slow return ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Filters payloads that are nulled by a later synchronous error", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles overlapping deferred and non-deferred streams", async () => { + const query = gql` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [{ id: "1", name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + // this test does not exist in the original test suite but added to ensure + // deferred non-empty lists are properly merged + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream with > 0 initialCount", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved before async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1", name: "Luke Cached" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 0ed7cd97f59..13a495f4ab6 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -29,6 +29,7 @@ export declare namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; export type SubsequentResult> = { @@ -36,20 +37,32 @@ export declare namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; - export type Chunk> = - | InitialResult - | SubsequentResult; - - export type IncrementalDeferPayload> = { - data?: TData | null | undefined; + export type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; + + export type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + + export type IncrementalResult> = + | IncrementalDeferResult + | IncrementalStreamResult; + + export type Chunk> = + | InitialResult + | SubsequentResult; } class DeferRequest> @@ -62,12 +75,15 @@ class DeferRequest> private extensions: Record = {}; private data: any = {}; - private mergeIn( + private merge( normalized: FormattedExecutionResult, - merger: DeepMerger + arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate" ) { if (normalized.data !== undefined) { - this.data = merger.merge(this.data, normalized.data); + this.data = new DeepMerger(undefined, { arrayMerge }).merge( + this.data, + normalized.data + ); } if (normalized.errors) { this.errors.push(...normalized.errors); @@ -83,29 +99,40 @@ class DeferRequest> ): FormattedExecutionResult { this.hasNext = chunk.hasNext; this.data = cacheData; - - this.mergeIn(chunk, new DeepMerger()); + this.merge(chunk); if (hasIncrementalChunks(chunk)) { - const merger = new DeepMerger(); for (const incremental of chunk.incremental) { - let { data, path, errors, extensions } = incremental; - if (data && path) { + const { path, errors, extensions } = incremental; + let arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate"; + let data = + // The item merged from a `@stream` chunk is always the first item in + // the `items` array + "items" in incremental ? incremental.items?.[0] + // Ensure `data: null` isn't merged for `@defer` responses by + // falling back to `undefined` + : "data" in incremental ? incremental.data ?? undefined + : undefined; + + if (data !== undefined && path) { for (let i = path.length - 1; i >= 0; --i) { const key = path[i]; const isNumericKey = !isNaN(+key); const parent: Record = isNumericKey ? [] : {}; + if (isNumericKey) { + arrayMerge = "combine"; + } parent[key] = data; data = parent as typeof data; } } - this.mergeIn( + this.merge( { errors, extensions, data: data ? (data as TData) : undefined, }, - merger + arrayMerge ); } } @@ -162,7 +189,7 @@ export class Defer20220824Handler } prepareRequest(request: ApolloLink.Request): ApolloLink.Request { - if (hasDirectives(["defer"], request.query)) { + if (hasDirectives(["defer", "stream"], request.query)) { const context = request.context ?? {}; const http = (context.http ??= {}); http.accept = [ diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts new file mode 100644 index 00000000000..ee671c18504 --- /dev/null +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -0,0 +1,308 @@ +import type { + DocumentNode, + FormattedExecutionResult, + GraphQLFormattedError, +} from "graphql"; + +import type { ApolloLink } from "@apollo/client/link"; +import type { DeepPartial, HKT } from "@apollo/client/utilities"; +import { DeepMerger } from "@apollo/client/utilities/internal"; +import { + hasDirectives, + isNonEmptyArray, +} from "@apollo/client/utilities/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +import type { Incremental } from "../types.js"; + +export declare namespace GraphQL17Alpha9Handler { + interface GraphQL17Alpha9Result extends HKT { + arg1: unknown; // TData + arg2: unknown; // TExtensions + return: GraphQL17Alpha9Handler.Chunk>; + } + + export interface TypeOverrides { + AdditionalApolloLinkResultTypes: GraphQL17Alpha9Result; + } + + export type InitialResult> = { + data: TData; + errors?: ReadonlyArray; + pending: ReadonlyArray; + hasNext: boolean; + extensions?: Record; + }; + + export type SubsequentResult = { + hasNext: boolean; + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + extensions?: Record; + }; + + export interface PendingResult { + id: string; + path: Incremental.Path; + label?: string; + } + + export interface CompletedResult { + id: string; + errors?: ReadonlyArray; + } + + export interface IncrementalDeferResult> { + errors?: ReadonlyArray; + data: TData; + id: string; + subPath?: Incremental.Path; + extensions?: Record; + } + + export interface IncrementalStreamResult> { + errors?: ReadonlyArray; + items: TData; + id: string; + subPath?: Incremental.Path; + extensions?: Record; + } + + export type IncrementalResult = + | IncrementalDeferResult + | IncrementalStreamResult; + + export type Chunk = InitialResult | SubsequentResult; +} + +class IncrementalRequest + implements + Incremental.IncrementalRequest, TData> +{ + hasNext = true; + + private data: any = {}; + private errors: GraphQLFormattedError[] = []; + private extensions: Record = {}; + private pending: GraphQL17Alpha9Handler.PendingResult[] = []; + // `streamPositions` maps `pending.id` to the index that should be set by the + // next `incremental` stream chunk to ensure the streamed array item is placed + // at the correct point in the data array. `this.data` contains cached + // references with the full array so we can't rely on the array length in + // `this.data` to determine where to place item. This also ensures that items + // updated by the cache between a streamed chunk aren't overwritten by merges + // of future stream items from already merged stream items. + private streamPositions: Record = {}; + + handle( + cacheData: TData | DeepPartial | null | undefined = this.data, + chunk: GraphQL17Alpha9Handler.Chunk + ): FormattedExecutionResult { + this.hasNext = chunk.hasNext; + this.data = cacheData; + + if (chunk.pending) { + this.pending.push(...chunk.pending); + + if ("data" in chunk) { + for (const pending of chunk.pending) { + const dataAtPath = pending.path.reduce( + (data, key) => (data as any)[key], + chunk.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pending.id] = dataAtPath.length; + } + } + } + } + + this.merge(chunk, "truncate"); + + if (hasIncrementalChunks(chunk)) { + for (const incremental of chunk.incremental) { + const pending = this.pending.find(({ id }) => incremental.id === id); + + invariant( + pending, + "Could not find pending chunk for incremental value. Please file an issue for the Apollo Client team to investigate." + ); + + const path = pending.path.concat(incremental.subPath ?? []); + + let data: any; + let arrayMerge: DeepMerger.ArrayMergeStrategy = "truncate"; + if ("items" in incremental) { + const items = incremental.items as any[]; + const parent: any[] = []; + + // This creates a sparse array with values set at the indices streamed + // from the server. DeepMerger uses Object.keys and will correctly + // place the values in this array in the correct place + for (let i = 0; i < items.length; i++) { + parent[i + this.streamPositions[pending.id]] = items[i]; + } + + this.streamPositions[pending.id] += items.length; + data = parent; + } else { + data = incremental.data; + + // Check if any pending streams added arrays from deferred data so + // that we can update streamPositions with the initial length of the + // array to ensure future streamed items are inserted at the right + // starting index. + for (const pendingItem of this.pending) { + if (!(pendingItem.id in this.streamPositions)) { + // Check if this incremental data contains array data for the pending path + // The pending path is absolute, but incremental data is relative to the defer + // E.g., pending.path = ["nestedObject"], pendingItem.path = ["nestedObject", "nestedFriendList"] + // incremental.data = { scalarField: "...", nestedFriendList: [...] } + // So we need the path from pending.path onwards + const relativePath = pendingItem.path.slice(pending.path.length); + const dataAtPath = relativePath.reduce( + (data, key) => (data as any)?.[key], + incremental.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pendingItem.id] = dataAtPath.length; + } + } + } + } + + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = data; + if (typeof key === "number") { + arrayMerge = "combine"; + } + data = parent; + } + + this.merge( + { + data, + extensions: incremental.extensions, + errors: incremental.errors, + }, + arrayMerge + ); + } + } + + if ("completed" in chunk && chunk.completed) { + for (const completed of chunk.completed) { + this.pending = this.pending.filter(({ id }) => id !== completed.id); + + if (completed.errors) { + this.errors.push(...completed.errors); + } + } + } + + const result: FormattedExecutionResult = { data: this.data }; + + if (isNonEmptyArray(this.errors)) { + result.errors = this.errors; + } + + if (Object.keys(this.extensions).length > 0) { + result.extensions = this.extensions; + } + + return result; + } + + private merge( + normalized: FormattedExecutionResult, + arrayMerge: DeepMerger.ArrayMergeStrategy + ) { + if (normalized.data !== undefined) { + this.data = new DeepMerger(undefined, { arrayMerge }).merge( + this.data, + normalized.data + ); + } + + if (normalized.errors) { + this.errors.push(...normalized.errors); + } + + Object.assign(this.extensions, normalized.extensions); + } +} + +/** + * Provides handling for the incremental delivery specification implemented by + * graphql.js version `17.0.0-alpha.9`. + */ +export class GraphQL17Alpha9Handler + implements Incremental.Handler> +{ + /** @internal */ + isIncrementalResult( + result: ApolloLink.Result + ): result is + | GraphQL17Alpha9Handler.InitialResult + | GraphQL17Alpha9Handler.SubsequentResult { + return "hasNext" in result; + } + + /** @internal */ + prepareRequest(request: ApolloLink.Request): ApolloLink.Request { + if (hasDirectives(["defer", "stream"], request.query)) { + const context = request.context ?? {}; + const http = (context.http ??= {}); + // https://specs.apollo.dev/incremental/v0.2/ + http.accept = [ + "multipart/mixed;incrementalSpec=v0.2", + ...(http.accept || []), + ]; + + request.context = context; + } + + return request; + } + + /** @internal */ + extractErrors(result: ApolloLink.Result) { + const acc: GraphQLFormattedError[] = []; + const push = ({ + errors, + }: { + errors?: ReadonlyArray; + }) => { + if (errors) { + acc.push(...errors); + } + }; + + if (this.isIncrementalResult(result)) { + push(new IncrementalRequest().handle(undefined, result)); + } else { + push(result); + } + + if (acc.length) { + return acc; + } + } + + /** @internal */ + startRequest(_: { query: DocumentNode }) { + return new IncrementalRequest(); + } +} + +function hasIncrementalChunks( + result: Record +): result is Required { + return isNonEmptyArray(result.incremental); +} diff --git a/src/incremental/handlers/notImplemented.ts b/src/incremental/handlers/notImplemented.ts index f4c02545c57..ce7f287ac00 100644 --- a/src/incremental/handlers/notImplemented.ts +++ b/src/incremental/handlers/notImplemented.ts @@ -22,8 +22,8 @@ export class NotImplementedHandler implements Incremental.Handler { } prepareRequest(request: ApolloLink.Request) { invariant( - !hasDirectives(["defer"], request.query), - "`@defer` is not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." + !hasDirectives(["defer", "stream"], request.query), + "`@defer` and `@stream` are not supported without specifying an incremental handler. Please pass a handler as the `incrementalHandler` option to the `ApolloClient` constructor." ); return request; diff --git a/src/incremental/index.ts b/src/incremental/index.ts index c340efe8574..334b0dcc826 100644 --- a/src/incremental/index.ts +++ b/src/incremental/index.ts @@ -4,3 +4,4 @@ export { Defer20220824Handler, Defer20220824Handler as GraphQL17Alpha2Handler, } from "./handlers/defer20220824.js"; +export { GraphQL17Alpha9Handler } from "./handlers/graphql17Alpha9.js"; diff --git a/src/link/error/__tests__/index.ts b/src/link/error/__tests__/index.ts index 50e814e811e..92928c77746 100644 --- a/src/link/error/__tests__/index.ts +++ b/src/link/error/__tests__/index.ts @@ -13,7 +13,7 @@ import { ApolloLink } from "@apollo/client/link"; import { ErrorLink } from "@apollo/client/link/error"; import { executeWithDefaultContext as execute, - mockDeferStream, + mockDefer20220824, mockMultipartSubscriptionStream, ObservableStream, wait, @@ -214,7 +214,7 @@ describe("error handling", () => { const errorLink = new ErrorLink(callback); const { httpLink, enqueueInitialChunk, enqueueErrorChunk } = - mockDeferStream(); + mockDefer20220824(); const link = errorLink.concat(httpLink); const stream = new ObservableStream(execute(link, { query })); diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index a60975fd827..53586aeb351 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -19,7 +19,10 @@ import { PROTOCOL_ERRORS_SYMBOL, ServerParseError, } from "@apollo/client/errors"; -import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { BaseHttpLink, HttpLink } from "@apollo/client/link/http"; import { @@ -57,6 +60,15 @@ const sampleDeferredQuery = gql` } `; +const sampleStreamedQuery = gql` + query SampleDeferredQuery { + stubs @stream { + id + name + } + } +`; + const sampleQueryCustomDirective = gql` query SampleDeferredQuery { stub { @@ -1341,6 +1353,57 @@ describe("HttpLink", () => { "-----", ].join("\r\n"); + const bodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"pending":[{"id":"0","path":["stub"]}],"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"name":"stubby---"},"id":"0","extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBody = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"id":"1","name":"stubby---"},"path":["stubs", 1],"extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"pending": [{"id":"0","path":["stubs"]}], "hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"items":[{"id":"1","name":"stubby---"}],"id":"0","extensions":{"timestamp":1633038919}}],"completed":[{"id":"0"}]}', + "-----", + ].join("\r\n"); + const finalChunkOnlyHasNextFalse = [ "--graphql", "content-type: application/json", @@ -1524,6 +1587,169 @@ describe("HttpLink", () => { ); }); + it("sets correct accept header on request with deferred query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + bodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleDeferredQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stub: { id: "0" } }, + // @ts-ignore + pending: [{ id: "0", path: ["stub"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { name: "stubby---" }, + // @ts-ignore + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query", async () => { + const stream = ReadableStream.from( + streamBody.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { id: "1", name: "stubby---" }, + path: ["stubs", 1], + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + streamBodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + // @ts-ignore + pending: [{ id: "0", path: ["stubs"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + // @ts-ignore + items: [{ id: "1", name: "stubby---" }], + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + // ensure that custom directives beginning with '@defer..' do not trigger // custom accept header for multipart responses it("sets does not set accept header on query with custom directive begging with @defer", async () => { diff --git a/src/local-state/LocalState.ts b/src/local-state/LocalState.ts index f25c7c9c873..49601ba59ec 100644 --- a/src/local-state/LocalState.ts +++ b/src/local-state/LocalState.ts @@ -22,6 +22,7 @@ import type { ErrorLike, OperationVariables, TypedDocumentNode, + WatchQueryFetchPolicy, } from "@apollo/client"; import { cacheSlot } from "@apollo/client/cache"; import { LocalStateError, toErrorLike } from "@apollo/client/errors"; @@ -63,6 +64,7 @@ interface ExecContext { exportedVariableDefs: Record; diff: Cache.DiffResult; returnPartialData: boolean; + fetchPolicy?: WatchQueryFetchPolicy; } /** @@ -336,6 +338,7 @@ export class LocalState< variables = {} as TVariables, onlyRunForcedResolvers = false, returnPartialData = false, + fetchPolicy, }: { document: DocumentNode | TypedDocumentNode; client: ApolloClient; @@ -345,6 +348,7 @@ export class LocalState< variables: TVariables | undefined; onlyRunForcedResolvers?: boolean; returnPartialData?: boolean; + fetchPolicy: WatchQueryFetchPolicy; }): Promise> { if (__DEV__) { invariant( @@ -372,12 +376,15 @@ export class LocalState< const rootValue = remoteResult ? remoteResult.data : {}; - const diff = client.cache.diff>({ - query: toQueryOperation(document), - variables, - returnPartialData: true, - optimistic: false, - }); + const diff: Cache.DiffResult> = + fetchPolicy === "no-cache" ? + { result: null, complete: false } + : client.cache.diff>({ + query: toQueryOperation(document), + variables, + returnPartialData: true, + optimistic: false, + }); const requestContext = { ...client.defaultContext, ...context }; const execContext: ExecContext = { @@ -401,6 +408,7 @@ export class LocalState< exportedVariableDefs, diff, returnPartialData, + fetchPolicy, }; const localResult = await this.resolveSelectionSet( @@ -676,9 +684,10 @@ export class LocalState< variables, operationDefinition, phase, - returnPartialData, onlyRunForcedResolvers, + fetchPolicy, } = execContext; + let { returnPartialData } = execContext; const isRootField = parentSelectionSet === operationDefinition.selectionSet; const fieldName = field.name.value; const typename = @@ -709,7 +718,25 @@ export class LocalState< return fieldFromCache; } + if (client.cache.resolvesClientField?.(typename, fieldName)) { + if (fetchPolicy === "no-cache") { + invariant.warn( + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", + resolverName + ); + return null; + } + + // assume the cache will handle returning the correct value + returnPartialData = true; + return; + } + if (!returnPartialData) { + invariant.warn( + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + resolverName + ); return null; } } diff --git a/src/local-state/__tests__/LocalState/aliases.test.ts b/src/local-state/__tests__/LocalState/aliases.test.ts index 197dcc48c78..b88b438c370 100644 --- a/src/local-state/__tests__/LocalState/aliases.test.ts +++ b/src/local-state/__tests__/LocalState/aliases.test.ts @@ -41,6 +41,7 @@ test("resolves @client fields mixed with aliased server fields", async () => { context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -81,6 +82,7 @@ test("resolves aliased @client fields", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { fie: { bar: true, __typename: "Foo" } }, @@ -137,6 +139,7 @@ test("resolves deeply nested aliased @client fields", async () => { context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -190,6 +193,7 @@ test("respects aliases for *nested fields* on the @client-tagged node", async () context: {}, remoteResult, variables: {}, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -231,6 +235,7 @@ test("does not confuse fields aliased to each other", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -269,6 +274,7 @@ test("does not confuse fields aliased to each other with boolean values", async context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/async.test.ts b/src/local-state/__tests__/LocalState/async.test.ts index 5d4ff21f0bf..a8079360b3f 100644 --- a/src/local-state/__tests__/LocalState/async.test.ts +++ b/src/local-state/__tests__/LocalState/async.test.ts @@ -33,6 +33,7 @@ test("supports async @client resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { isLoggedIn: true }, @@ -130,6 +131,7 @@ test("handles nested asynchronous @client resolvers", async () => { context: {}, variables: { id: developerId }, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -201,6 +203,7 @@ test("supports async @client resolvers mixed with remotely resolved data", async context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/base.test.ts b/src/local-state/__tests__/LocalState/base.test.ts index cc985217082..6fbbb8377a3 100644 --- a/src/local-state/__tests__/LocalState/base.test.ts +++ b/src/local-state/__tests__/LocalState/base.test.ts @@ -4,7 +4,7 @@ import { LocalState } from "@apollo/client/local-state"; import { spyOnConsole } from "@apollo/client/testing/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; -import { gql } from "./testUtils.js"; +import { gql, WARNINGS } from "./testUtils.js"; test("runs resolvers for @client queries", async () => { const document = gql` @@ -34,6 +34,7 @@ test("runs resolvers for @client queries", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -69,6 +70,7 @@ test("can add resolvers after LocalState is instantiated", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -108,6 +110,7 @@ test("handles queries with a mix of @client and server fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -165,6 +168,7 @@ test("runs resolvers for deeply nested @client fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -214,6 +218,7 @@ test("has access to query variables in @client resolvers", async () => { context: {}, variables: { id: 1 }, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -266,6 +271,7 @@ test("combines local @client resolver results with server results, for the same context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -309,6 +315,7 @@ test("handles resolvers that return booleans", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { isInCart: false }, @@ -351,6 +358,7 @@ test("does not run resolvers without @client directive", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -405,6 +413,7 @@ test("does not run resolvers without @client directive with nested field", async context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -462,6 +471,7 @@ test("allows child resolvers from a parent resolved field from a local resolver" context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -518,6 +528,7 @@ test("can use remote result to resolve @client field", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -567,13 +578,14 @@ test("throws error when query does not contain client fields", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError("Expected document to contain `@client` fields.") ); }); -test("does not warn when a resolver is missing for an `@client` field", async () => { +test("warns and sets value to null when a resolver is missing for an `@client` field and a read function is not defined when using InMemoryCache", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -595,13 +607,57 @@ test("does not warn when a resolver is missing for an `@client` field", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null } }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Query.foo" + ); +}); + +test("does not warn when read function is defined for a `@client` field when using InMemoryCache", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo @client + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + foo: { + read: () => "Bar", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: undefined, + fetchPolicy: "cache-first", + }) + ).resolves.toStrictEqualTyped({ data: { foo: "Bar" } }); + expect(console.warn).not.toHaveBeenCalled(); }); -test("does not warn for client child fields of a server field", async () => { +test("warns and sets value to null for client child fields of a server field with no resolver or read function", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -625,14 +681,149 @@ test("does not warn for client child fields of a server field", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null } }, }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Foo.bar" + ); +}); + +test("does not warn when a read function is defined for a child `@client` field from a server field when using InMemoryCache", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo { + bar @client + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Foo: { + fields: { + bar: { + read: () => "Baz", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + const remoteResult = { data: { foo: { __typename: "Foo" } } }; + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult, + fetchPolicy: "cache-first", + }) + ).resolves.toStrictEqualTyped({ + // The `bar` field is not so that the cache can fill in the field from the + // read function. + data: { foo: { __typename: "Foo" } }, + }); + expect(console.warn).not.toHaveBeenCalled(); }); +test("warns when using a no-cache query with a read function but no resolver function", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo @client + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + foo: { + read: () => "bar", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: undefined, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ data: { foo: null } }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(WARNINGS.NO_CACHE, "Query.foo"); +}); + +test("warns when using a no-cache query with a read function but no resolver function on child @client field", async () => { + using _ = spyOnConsole("warn"); + const document = gql` + query { + foo { + bar @client + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Foo: { + fields: { + bar: { + read: () => "baz", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + const localState = new LocalState(); + + await expect( + localState.execute({ + document, + client, + context: {}, + variables: {}, + remoteResult: { data: { foo: { __typename: "Foo" } } }, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ + data: { foo: { __typename: "Foo", bar: null } }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(WARNINGS.NO_CACHE, "Foo.bar"); +}); + test("warns when a resolver returns undefined and sets value to null", async () => { using _ = spyOnConsole("warn"); const document = gql` @@ -661,6 +852,7 @@ test("warns when a resolver returns undefined and sets value to null", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null } }); @@ -701,6 +893,7 @@ test("warns if a parent resolver omits a field with no child resolver", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true, baz: null } }, @@ -745,6 +938,7 @@ test("warns if a parent resolver omits a field and child has @client field", asy context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true, baz: null } }, @@ -791,6 +985,7 @@ test("adds an error when the __typename cannot be resolved", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -844,6 +1039,7 @@ test("can return more data than needed in resolver which is accessible by child context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: "random" } }, @@ -882,6 +1078,7 @@ test("does not execute child resolver when parent is null", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { currentUser: null }, @@ -929,6 +1126,7 @@ test("does not execute root scalar resolver data when remote data returns null", context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -979,6 +1177,7 @@ test("does not run object resolver when remote data returns null", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -1037,6 +1236,7 @@ test("does not run root resolvers when multiple client fields are defined when r context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, @@ -1084,6 +1284,7 @@ test("does not execute resolver if client field is a child of a server field whe context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: null, diff --git a/src/local-state/__tests__/LocalState/cache.test.ts b/src/local-state/__tests__/LocalState/cache.test.ts index 88c9d9abe82..ad8a8d4a319 100644 --- a/src/local-state/__tests__/LocalState/cache.test.ts +++ b/src/local-state/__tests__/LocalState/cache.test.ts @@ -7,7 +7,7 @@ import { import { LocalState } from "@apollo/client/local-state"; import { spyOnConsole } from "@apollo/client/testing/internal"; -import { gql } from "./testUtils.js"; +import { gql, WARNINGS } from "./testUtils.js"; test("can write to the cache with a mutation", async () => { const query = gql` @@ -45,6 +45,7 @@ test("can write to the cache with a mutation", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -104,6 +105,7 @@ test("can write to the cache with a mutation using an ID", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -174,6 +176,7 @@ test("does not overwrite __typename when writing to the cache with an id", async context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { start: true } }); @@ -214,6 +217,7 @@ test("reads from the cache on a root scalar field by default if a resolver is no context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: 10 } }); }); @@ -253,6 +257,7 @@ test("reads from the cache on a root object field by default if a resolver is no context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, @@ -292,6 +297,7 @@ test("handles read functions for root scalar field from cache if resolver is not context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: 10 } }); }); @@ -332,13 +338,14 @@ test("handles read functions for root object field from cache if resolver is not context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, }); }); -test("does not warn if resolver is not defined if cache does not have value", async () => { +test("warns if resolver or read function isn't defined if cache does not have value", async () => { using _ = spyOnConsole("warn"); const document = gql` query { @@ -360,10 +367,15 @@ test("does not warn if resolver is not defined if cache does not have value", as context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { count: null } }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "Query.count" + ); }); test("reads from the cache on a nested scalar field by default if a resolver is not defined", async () => { @@ -401,6 +413,7 @@ test("reads from the cache on a nested scalar field by default if a resolver is context: {}, variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, isLoggedIn: true } }, @@ -461,6 +474,7 @@ test("reads from the cache with a read function on a nested scalar field if a re remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, isLoggedIn: true } }, @@ -511,6 +525,7 @@ test("reads from the cache on a nested object field by default if a resolver is remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -580,6 +595,7 @@ test("reads from the cache with a read function on a nested object field by defa remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -647,6 +663,7 @@ test("reads from the cache on a nested client field on a non-normalized object", remoteResult: { data: { user: { __typename: "User" } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -697,6 +714,7 @@ test("does not confuse field missing resolver with root field of same name on a remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -709,7 +727,11 @@ test("does not confuse field missing resolver with root field of same name on a }, }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.count" + ); }); test("does not confuse field missing resolver with root field of same name on a non-normalized record", async () => { @@ -750,6 +772,7 @@ test("does not confuse field missing resolver with root field of same name on a remoteResult: { data: { user: { __typename: "User" } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -761,7 +784,11 @@ test("does not confuse field missing resolver with root field of same name on a }, }); - expect(console.warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + WARNINGS.MISSING_RESOLVER, + "User.count" + ); }); test("warns on undefined value if partial data is written to the cache for an object client field", async () => { @@ -814,6 +841,7 @@ test("warns on undefined value if partial data is written to the cache for an ob remoteResult: { data: { user: { __typename: "User", id: 1 } }, }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -880,6 +908,7 @@ test("uses a written cache value from a nested client field from parent resolver context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1, name: "Test User" } }, diff --git a/src/local-state/__tests__/LocalState/context.test.ts b/src/local-state/__tests__/LocalState/context.test.ts index 4f48d68fc26..d3d870f5c9c 100644 --- a/src/local-state/__tests__/LocalState/context.test.ts +++ b/src/local-state/__tests__/LocalState/context.test.ts @@ -34,6 +34,7 @@ test("passes client in context to resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -85,6 +86,7 @@ test("can access request context in resolvers", async () => { context: { id: 1 }, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: 1 } }, @@ -134,6 +136,7 @@ test("can access phase in resolver context", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: "resolve" } }, @@ -174,6 +177,7 @@ test("can use custom context function used as request context", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -220,6 +224,7 @@ test("context function can merge request context and custom context", async () = context: { isRequestBarEnabled: true }, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, diff --git a/src/local-state/__tests__/LocalState/errors.test.ts b/src/local-state/__tests__/LocalState/errors.test.ts index 5b4a19e4329..3cd699924ee 100644 --- a/src/local-state/__tests__/LocalState/errors.test.ts +++ b/src/local-state/__tests__/LocalState/errors.test.ts @@ -36,6 +36,7 @@ test("handles errors thrown in a resolver", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -88,6 +89,7 @@ test("handles errors thrown in a child resolver", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null } }, @@ -146,6 +148,7 @@ test("adds errors for each field that throws errors", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: null, baz: null, qux: true } }, @@ -208,6 +211,7 @@ test("handles errors thrown in a child resolver from parent array", async () => context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -283,6 +287,7 @@ test("handles errors thrown in a child resolver for an array from a single item" context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -339,6 +344,7 @@ test("serializes a thrown GraphQLError and merges extensions", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -393,6 +399,7 @@ test("overwrites localState extension from thrown GraphQLError if provided", asy context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -452,6 +459,7 @@ test("concatenates client errors with server errors", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null, baz: { __typename: "Baz", qux: null } }, @@ -502,6 +510,7 @@ test("handles errors thrown in async resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -551,6 +560,7 @@ test("handles rejected promises returned in async resolvers", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: null }, @@ -614,6 +624,7 @@ test("handles errors thrown for resolvers on fields inside fragments", async () context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -671,6 +682,7 @@ test("handles remote errors with no local resolver errors", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/forcedResolvers.test.ts b/src/local-state/__tests__/LocalState/forcedResolvers.test.ts index eb75a0845a2..68a4f3c61f5 100644 --- a/src/local-state/__tests__/LocalState/forcedResolvers.test.ts +++ b/src/local-state/__tests__/LocalState/forcedResolvers.test.ts @@ -40,6 +40,7 @@ test("runs resolvers marked with @client(always: true)", async () => { context: {}, variables: {}, remoteResult: { data: client.readQuery({ query: document }) }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -65,6 +66,7 @@ test("runs resolvers marked with @client(always: true)", async () => { context: {}, variables: {}, remoteResult: { data: client.readQuery({ query: document }) }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -120,6 +122,7 @@ test("only runs forced resolvers for fields marked with `@client(always: true)`, variables: {}, remoteResult: undefined, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { name: "John Smith", isLoggedIn: true }, @@ -185,6 +188,7 @@ test("runs nested forced resolvers from non-forced client descendant field", asy variables: {}, remoteResult: undefined, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -238,6 +242,7 @@ test("warns for client fields without cached data and resolvers when running for variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ // Note: name is null because we are only running forced resolvers and diff --git a/src/local-state/__tests__/LocalState/fragments.test.ts b/src/local-state/__tests__/LocalState/fragments.test.ts index 892fd371fc5..df7e31482ac 100644 --- a/src/local-state/__tests__/LocalState/fragments.test.ts +++ b/src/local-state/__tests__/LocalState/fragments.test.ts @@ -52,6 +52,7 @@ test("handles @client fields inside fragments", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -100,6 +101,7 @@ test("handles a mix of @client fields with fragments and server fields", async ( context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -154,6 +156,7 @@ it("matches fragments with fragment conditions", async () => { context: {}, variables: {}, remoteResult, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { @@ -200,6 +203,7 @@ test("throws when cache does not implement fragmentMatches", async () => { context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError( @@ -240,6 +244,7 @@ test("does not traverse fragment when fragment spread type condition does not ma context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo" } } }); }); @@ -277,6 +282,7 @@ test("can use a fragments on interface types defined by possibleTypes", async () context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/partialData.test.ts b/src/local-state/__tests__/LocalState/partialData.test.ts index a6eb3bf0f29..397f4862dbb 100644 --- a/src/local-state/__tests__/LocalState/partialData.test.ts +++ b/src/local-state/__tests__/LocalState/partialData.test.ts @@ -30,6 +30,7 @@ test("omits field and does not warn if resolver not defined when returnPartialDa variables: {}, remoteResult: { data: { user: { __typename: "User", id: 1 } } }, returnPartialData: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { user: { __typename: "User", id: 1 } }, @@ -81,6 +82,7 @@ test("omits client fields without cached values when running forced resolvers wi remoteResult: { data: { user: { __typename: "User", id: 1 } } }, returnPartialData: true, onlyRunForcedResolvers: true, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ // Note: name is omitted because we are only running forced resolvers and diff --git a/src/local-state/__tests__/LocalState/rootValue.test.ts b/src/local-state/__tests__/LocalState/rootValue.test.ts index ba0013dd584..fb0b2f9ebc4 100644 --- a/src/local-state/__tests__/LocalState/rootValue.test.ts +++ b/src/local-state/__tests__/LocalState/rootValue.test.ts @@ -37,6 +37,7 @@ test("passes parent value as empty object to root resolver for client-only query context: {}, variables: {}, remoteResult: undefined, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { foo: { __typename: "Foo", bar: true } }, @@ -79,6 +80,7 @@ test("passes rootValue as remote result to root resolver when server fields are context: {}, variables: {}, remoteResult: { data: { bar: { __typename: "Bar", baz: true } } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { diff --git a/src/local-state/__tests__/LocalState/subscriptions.test.ts b/src/local-state/__tests__/LocalState/subscriptions.test.ts index 81660e35b8b..30df1bf6b3b 100644 --- a/src/local-state/__tests__/LocalState/subscriptions.test.ts +++ b/src/local-state/__tests__/LocalState/subscriptions.test.ts @@ -26,6 +26,7 @@ test("throws when given a subscription with no client fields", async () => { context: {}, variables: {}, remoteResult: { data: { field: 1 } }, + fetchPolicy: "cache-first", }) ).rejects.toEqual( new InvariantError("Expected document to contain `@client` fields.") @@ -64,6 +65,7 @@ test("adds @client fields with subscription results", async () => { context: {}, variables: {}, remoteResult: { data: { field: 1 } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { field: 1, count: 1 }, @@ -76,6 +78,7 @@ test("adds @client fields with subscription results", async () => { context: {}, variables: {}, remoteResult: { data: { field: 2 } }, + fetchPolicy: "cache-first", }) ).resolves.toStrictEqualTyped({ data: { field: 2, count: 2 }, diff --git a/src/local-state/__tests__/LocalState/testUtils.ts b/src/local-state/__tests__/LocalState/testUtils.ts index 4e1c07fa21e..325bbdfcc27 100644 --- a/src/local-state/__tests__/LocalState/testUtils.ts +++ b/src/local-state/__tests__/LocalState/testUtils.ts @@ -4,3 +4,10 @@ import { addTypenameToDocument } from "@apollo/client/utilities"; export const gql = (...args: Parameters) => addTypenameToDocument(origGql(...args)); + +export const WARNINGS = { + MISSING_RESOLVER: + "Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.", + NO_CACHE: + "The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.", +}; diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index d0bce5acec0..6e4c07f6e7f 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -30,7 +30,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -1391,151 +1390,6 @@ it("works with startTransition to change variables", async () => { } }); -it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - interface Data { - greeting: { - __typename: string; - message: string; - recipient: { name: string; __typename: string }; - }; - } - - const query: TypedDocumentNode = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - const renderStream = createDefaultProfiler(); - - const { SuspenseFallback, ReadQueryHook } = - createDefaultTrackedComponents(renderStream); - - function App() { - useTrackRenders(); - const [queryRef] = useBackgroundQuery(query, { - fetchPolicy: "cache-and-network", - }); - - return ( - }> - - - ); - } - - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { wrapper: createClientWrapper(client) }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender({ timeout: 50 }); -}); - it("reacts to cache updates", async () => { const { query, mocks } = setupSimpleCase(); @@ -3816,159 +3670,6 @@ it('suspends and does not use partial data when changing variables and using a " await expect(renderStream).not.toRerender({ timeout: 50 }); }); -it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - interface QueryData { - greeting: { - __typename: string; - message?: string; - recipient?: { - __typename: string; - name: string; - }; - }; - } - - const query: TypedDocumentNode = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const renderStream = createDefaultProfiler>(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultTrackedComponents(renderStream); - - function App() { - useTrackRenders(); - const [queryRef] = useBackgroundQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }); - - return ( - }> - - - ); - } - - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { wrapper: createClientWrapper(client) }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender({ timeout: 50 }); -}); - it.each([ "cache-first", "network-only", diff --git a/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..9ce0c721105 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/defer20220824.test.tsx @@ -0,0 +1,357 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..1efc081c759 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,361 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..bff598f1047 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx @@ -0,0 +1,419 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..e5010152249 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,419 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => { + return incoming; + }, + }, + }, + }, + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index 53818cc4eda..bcd4be56365 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -32,7 +32,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -1531,164 +1530,6 @@ it("works with startTransition to change variables", async () => { }); }); -it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - interface Data { - greeting: { - __typename: string; - message: string; - recipient: { name: string; __typename: string }; - }; - } - - const query: TypedDocumentNode> = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const renderStream = createDefaultProfiler(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultProfiledComponents(renderStream); - - function App() { - useTrackRenders(); - const [loadQuery, queryRef] = useLoadableQuery(query, { - fetchPolicy: "cache-and-network", - }); - return ( -
- - }> - {queryRef && } - -
- ); - } - - const { user } = await renderWithClient( - , - { - client, - }, - renderStream - ); - - // initial render - await renderStream.takeRender(); - - await user.click(screen.getByText("Load todo")); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender(); -}); - it("reacts to cache updates", async () => { const { query, mocks } = useSimpleQueryCase(); const client = new ApolloClient({ @@ -4553,174 +4394,6 @@ it('suspends and does not use partial data when changing variables and using a " } }); -it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - interface QueryData { - greeting: { - __typename: string; - message?: string; - recipient?: { - __typename: string; - name: string; - }; - }; - } - - const query: TypedDocumentNode> = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - { - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - using _consoleSpy = spyOnConsole("error"); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const renderStream = createDefaultProfiler>(); - const { SuspenseFallback, ReadQueryHook } = - createDefaultProfiledComponents(renderStream); - - function App() { - useTrackRenders(); - const [loadTodo, queryRef] = useLoadableQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }); - - return ( -
- - }> - {queryRef && } - -
- ); - } - - const { user } = await renderWithClient( - , - { - client, - }, - renderStream - ); - - // initial render - await renderStream.takeRender(); - - await user.click(screen.getByText("Load todo")); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - error: undefined, - networkStatus: NetworkStatus.loading, - }); - } - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - - await expect(renderStream).not.toRerender(); -}); - it("throws when calling loadQuery on first render", async () => { // We don't provide this functionality with React 19 anymore since it requires internals access if (IS_REACT_19) return; diff --git a/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..26a07ede75c --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/defer20220824.test.tsx @@ -0,0 +1,402 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderHook< + TData, + TVariables extends OperationVariables, + TStates extends DataState["dataState"] = DataState["dataState"], + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useLoadableQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ + queryRef, + }: { + queryRef: QueryRef; + }) { + useTrackRenders({ name: "useReadQuery" }); + mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = renderHook(props as any); + + mergeSnapshot({ loadQuery }); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { + render, + getCurrentRender, + takeRender, + mergeSnapshot, + replaceSnapshot, + } = createRenderStream< + | { + loadQuery: useLoadableQuery.LoadQueryFunction; + result?: useReadQuery.Result; + } + | { error: ErrorLike } + >({ initialSnapshot: { loadQuery: null as any } }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + invariant( + "loadQuery" in snapshot, + "Expected rendered hook instead of error boundary" + ); + + return snapshot; + } + + return { takeRender, rerender, getCurrentSnapshot }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => useLoadableQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache(); + + { + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + using _consoleSpy = spyOnConsole("error"); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => + useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..c4fee82fef3 --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,406 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderHook< + TData, + TVariables extends OperationVariables, + TStates extends DataState["dataState"] = DataState["dataState"], + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useLoadableQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ + queryRef, + }: { + queryRef: QueryRef; + }) { + useTrackRenders({ name: "useReadQuery" }); + mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = renderHook(props as any); + + mergeSnapshot({ loadQuery }); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { + render, + getCurrentRender, + takeRender, + mergeSnapshot, + replaceSnapshot, + } = createRenderStream< + | { + loadQuery: useLoadableQuery.LoadQueryFunction; + result?: useReadQuery.Result; + } + | { error: ErrorLike } + >({ initialSnapshot: { loadQuery: null as any } }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + invariant( + "loadQuery" in snapshot, + "Expected rendered hook instead of error boundary" + ); + + return snapshot; + } + + return { takeRender, rerender, getCurrentSnapshot }; +} + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + interface Data { + greeting: { + __typename: string; + message: string; + recipient: { name: string; __typename: string }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => useLoadableQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + interface QueryData { + greeting: { + __typename: string; + message?: string; + recipient?: { + __typename: string; + name: string; + }; + }; + } + + const query: TypedDocumentNode> = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache(); + + { + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + using _consoleSpy = spyOnConsole("error"); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderHook( + () => + useLoadableQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + getCurrentSnapshot().loadQuery(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "useReadQuery", + ]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index f4e01ba122a..3f925c5c510 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -26,10 +26,9 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { BatchHttpLink } from "@apollo/client/link/batch-http"; import { ApolloProvider, useMutation, useQuery } from "@apollo/client/react"; -import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; +import { MockLink } from "@apollo/client/testing"; import { spyOnConsole, wait } from "@apollo/client/testing/internal"; import { MockedProvider } from "@apollo/client/testing/react"; import type { DeepPartial } from "@apollo/client/utilities"; @@ -3922,381 +3921,6 @@ describe("useMutation Hook", () => { await waitFor(() => screen.findByText("item 3")); }); }); - describe("defer", () => { - const CREATE_TODO_MUTATION_DEFER = gql` - mutation createTodo($description: String!, $priority: String) { - createTodo(description: $description, priority: $priority) { - id - ... @defer { - description - priority - } - } - } - `; - const variables = { - description: "Get milk!", - }; - it("resolves a deferred mutation with the full result", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [mutate] = getCurrentSnapshot(); - - const promise = mutate({ variables }); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot).not.toRerender(); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - { - const [, mutation] = await takeSnapshot(); - - expect(mutation).toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - error: undefined, - loading: false, - called: true, - }); - } - - await expect(promise).resolves.toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }); - - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - - it("resolves with resulting errors and calls onError callback", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - const onError = jest.fn(); - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER, { onError }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [createTodo] = getCurrentSnapshot(); - - const promise = createTodo({ variables }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot).not.toRerender(); - - link.simulateResult( - { - result: { - incremental: [ - { - data: null, - errors: [{ message: CREATE_TODO_ERROR }], - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(promise).rejects.toThrow( - new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: new CombinedGraphQLErrors({ - data: { createTodo: { __typename: "Todo", id: 1 } }, - errors: [{ message: CREATE_TODO_ERROR }], - }), - loading: false, - called: true, - }); - } - - await expect(takeSnapshot).not.toRerender(); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenLastCalledWith( - new CombinedGraphQLErrors({ - data: { createTodo: { __typename: "Todo", id: 1 } }, - errors: [{ message: CREATE_TODO_ERROR }], - }), - expect.anything() - ); - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - - it("calls the update function with the final merged result data", async () => { - using consoleSpies = spyOnConsole("error"); - const link = new MockSubscriptionLink(); - const update = jest.fn(); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot, getCurrentSnapshot } = - await renderHookToSnapshotStream( - () => useMutation(CREATE_TODO_MUTATION_DEFER, { update }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: false, - called: false, - }); - } - - const [createTodo] = getCurrentSnapshot(); - - const promiseReturnedByMutate = createTodo({ variables }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: undefined, - error: undefined, - loading: true, - called: true, - }); - } - - link.simulateResult({ - result: { - data: { - createTodo: { - id: 1, - __typename: "Todo", - }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot).not.toRerender(); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - path: ["createTodo"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }); - - { - const [, result] = await takeSnapshot(); - - expect(result).toStrictEqualTyped({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - error: undefined, - loading: false, - called: true, - }); - } - - await expect(takeSnapshot).not.toRerender(); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith( - // the first item is the cache, which we don't need to make any - // assertions against in this test - expect.anything(), - // second argument is the result - expect.objectContaining({ - data: { - createTodo: { - id: 1, - description: "Get milk!", - priority: "High", - __typename: "Todo", - }, - }, - }), - // third argument is an object containing context and variables - // but we only care about variables here - expect.objectContaining({ variables }) - ); - - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - }); }); describe("data masking", () => { diff --git a/src/react/hooks/__tests__/useMutation/context.test.tsx b/src/react/hooks/__tests__/useMutation/context.test.tsx new file mode 100644 index 00000000000..aae00dd8693 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/context.test.tsx @@ -0,0 +1,434 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import React from "react"; +import { delay, of } from "rxjs"; + +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ApolloProvider, useMutation } from "@apollo/client/react"; + +const echoContextLink = new ApolloLink((operation) => { + // filter out internal client set context values + const { queryDeduplication, optimisticResponse, ...context } = + operation.getContext(); + return of({ + data: { echo: { context } }, + }).pipe(delay(20)); +}); + +test("context is provided from hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute(); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { echo: { context: { foo: true } } }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("context provided to execute function overrides hook context", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute({ context: { bar: true } }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { echo: { context: { bar: true } } }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("allows context as callback called with context from hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn((ctx) => ({ ...ctx, bar: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith({ foo: true }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { foo: true, bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("provides undefined to context callback if context is not provided to hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn((ctx) => ({ ...ctx, bar: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith(undefined); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("does not merge returned context from context callback with hook", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + const contextFn = jest.fn(() => ({ baz: true })); + await execute({ context: contextFn }); + + expect(contextFn).toHaveBeenCalledTimes(1); + expect(contextFn).toHaveBeenCalledWith({ foo: true }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { baz: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("provides full context returned from callback to update function", async () => { + const mutation = gql` + mutation { + echo { + context + } + } + `; + + const client = new ApolloClient({ + link: echoContextLink, + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const update = jest.fn(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { context: { foo: true }, update }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [execute] = getCurrentSnapshot(); + + await execute({ context: (ctx) => ({ ...ctx, bar: true }) }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + echo: { context: { foo: true, bar: true } }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + client.cache, + { + data: { + echo: { context: { foo: true, bar: true } }, + }, + }, + { context: { foo: true, bar: true }, variables: {} } + ); +}); diff --git a/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx b/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx new file mode 100644 index 00000000000..5319ccdc587 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/defer20220824.test.tsx @@ -0,0 +1,389 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { gql } from "graphql-tag"; + +import { ApolloClient, CombinedGraphQLErrors } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useMutation } from "@apollo/client/react"; +import { + createClientWrapper, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const CREATE_TODO_ERROR = "Failed to create item"; + +test("resolves a deferred mutation with the full result", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate({ variables }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + expect(console.error).not.toHaveBeenCalled(); +}); + +test("resolves with resulting errors and calls onError callback", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const onError = jest.fn(); + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { onError }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promise = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: null, + errors: [{ message: CREATE_TODO_ERROR }], + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + await expect(promise).rejects.toThrow( + new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenLastCalledWith( + new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + expect.anything() + ); + expect(console.error).not.toHaveBeenCalled(); +}); + +test("calls the update function with the final merged result data", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const update = jest.fn(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { update }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promiseReturnedByMutate = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + path: ["createTodo"], + }, + ], + hasNext: false, + }); + + await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + // the first item is the cache, which we don't need to make any + // assertions against in this test + expect.anything(), + // second argument is the result + expect.objectContaining({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }), + // third argument is an object containing context and variables + // but we only care about variables here + expect.objectContaining({ variables }) + ); + + expect(console.error).not.toHaveBeenCalled(); +}); diff --git a/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..6cf690be0ef --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,388 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { gql } from "graphql-tag"; + +import { ApolloClient, CombinedGraphQLErrors } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useMutation } from "@apollo/client/react"; +import { + createClientWrapper, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const CREATE_TODO_ERROR = "Failed to create item"; + +test("resolves a deferred mutation with the full result", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate({ variables }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const [, mutation] = await takeSnapshot(); + + expect(mutation).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + expect(console.error).not.toHaveBeenCalled(); +}); + +test("resolves with resulting errors and calls onError callback", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const onError = jest.fn(); + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { onError }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promise = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + completed: [{ id: "0", errors: [{ message: CREATE_TODO_ERROR }] }], + hasNext: false, + }); + + await expect(promise).rejects.toThrow( + new CombinedGraphQLErrors({ errors: [{ message: CREATE_TODO_ERROR }] }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenLastCalledWith( + new CombinedGraphQLErrors({ + data: { createTodo: { __typename: "Todo", id: 1 } }, + errors: [{ message: CREATE_TODO_ERROR }], + }), + expect.anything() + ); + expect(console.error).not.toHaveBeenCalled(); +}); + +test("calls the update function with the final merged result data", async () => { + using _ = spyOnConsole("error"); + const mutation = gql` + mutation createTodo($description: String!, $priority: String) { + createTodo(description: $description, priority: $priority) { + id + ... @defer { + description + priority + } + } + } + `; + const variables = { + description: "Get milk!", + }; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const update = jest.fn(); + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { update }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: false, + called: false, + }); + } + + const [createTodo] = getCurrentSnapshot(); + + const promiseReturnedByMutate = createTodo({ variables }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + called: true, + }); + } + + enqueueInitialChunk({ + data: { + createTodo: { + id: 1, + __typename: "Todo", + }, + }, + pending: [{ id: "0", path: ["createTodo"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(promiseReturnedByMutate).resolves.toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + error: undefined, + loading: false, + called: true, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + // the first item is the cache, which we don't need to make any + // assertions against in this test + expect.anything(), + // second argument is the result + expect.objectContaining({ + data: { + createTodo: { + id: 1, + description: "Get milk!", + priority: "High", + __typename: "Todo", + }, + }, + }), + // third argument is an object containing context and variables + // but we only care about variables here + expect.objectContaining({ variables }) + ); + + expect(console.error).not.toHaveBeenCalled(); +}); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 2d544bb11ac..5845bac6001 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -34,7 +34,6 @@ import { NetworkStatus, } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { LocalState } from "@apollo/client/local-state"; import type { Unmasked } from "@apollo/client/masking"; @@ -53,7 +52,6 @@ import type { } from "@apollo/client/testing/internal"; import { enableFakeTimers, - markAsStreaming, setupPaginatedCase, setupSimpleCase, setupVariablesCase, @@ -10191,1226 +10189,6 @@ describe("useQuery Hook", () => { }); }); - describe("defer", () => { - it("should handle deferred queries", async () => { - const query = gql` - { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { - name: "Alice", - __typename: "Person", - }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries in lists", async () => { - const query = gql` - { - greetings { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greetings", 0], - }, - ], - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { message: "Hello again", __typename: "Greeting" }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greetings: [ - { message: "Hello world", __typename: "Greeting" }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Bob", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greetings", 1], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { - message: "Hello again", - __typename: "Greeting", - recipient: { name: "Bob", __typename: "Person" }, - }, - ], - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greetings: [ - { - message: "Hello world", - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - { message: "Hello again", __typename: "Greeting" }, - ], - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries in lists, merging arrays", async () => { - const query = gql` - query DeferVariation { - allProducts { - delivery { - ...MyFragment @defer - } - sku - id - } - } - fragment MyFragment on DeliveryEstimates { - estimatedDelivery - fastestDelivery - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - hasNext: true, - incremental: [ - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 0, "delivery"], - }, - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 1, "delivery"], - }, - ], - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - variables: {}, - }); - }); - - it("should handle deferred queries with fetch policy no-cache", async () => { - const query = gql` - { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { fetchPolicy: "no-cache" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { - name: "Alice", - __typename: "Person", - }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { - name: "Alice", - __typename: "Person", - }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - message: "Hello world", - __typename: "Greeting", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it("should handle deferred queries with errors returned on the incremental batched result", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - loading: false, - networkStatus: NetworkStatus.error, - previousData: undefined, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - incrementalHandler: new Defer20220824Handler(), - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { errorPolicy: "all" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: undefined, - dataState: "empty", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: undefined, - variables: {}, - }); - - setTimeout(() => { - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - extensions: { - thing1: "foo", - thing2: "bar", - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - extensions: { - thing1: "foo", - thing2: "bar", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - // the only difference with the previous test - // is that homeWorld is populated since errorPolicy: all - // populates both partial data and error.graphQLErrors - homeWorld: null, - id: "1000", - name: "Luke Skywalker", - }, - { - // homeWorld is populated due to errorPolicy: all - homeWorld: "Alderaan", - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { homeWorld: null, id: "1000", name: "Luke Skywalker" }, - { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - extensions: { - thing1: "foo", - thing2: "bar", - }, - }), - loading: false, - networkStatus: NetworkStatus.error, - previousData: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => useQuery(query, { fetchPolicy: "cache-and-network" }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - link.simulateResult({ - result: { - data: { - greeting: { __typename: "Greeting", message: "Hello world" }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - - it('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - // We know we are writing partial data to the cache so suppress the console - // warning. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - using _disabledAct = disableActEnvironment(); - const { takeSnapshot } = await renderHookToSnapshotStream( - () => - useQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - loading: true, - networkStatus: NetworkStatus.loading, - previousData: undefined, - variables: {}, - }); - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - loading: true, - networkStatus: NetworkStatus.streaming, - previousData: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await expect(takeSnapshot()).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - previousData: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - variables: {}, - }); - - await expect(takeSnapshot).not.toRerender(); - }); - }); - describe("interaction with `prioritizeCacheValues`", () => { const cacheData = { something: "foo" }; const emptyData = undefined; diff --git a/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..d15c2e78200 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/defer20220824.test.tsx @@ -0,0 +1,1148 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { + ApolloClient, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +test("should handle deferred queries", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists", async () => { + const query = gql` + { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greetings", 0], + }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Bob", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greetings", 1], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { + message: "Hello again", + __typename: "Greeting", + recipient: { name: "Bob", __typename: "Person" }, + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists, merging arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + hasNext: true, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + variables: {}, + }); +}); + +test("should handle deferred queries with fetch policy no-cache", async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries with errors returned on the incremental batched result", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + // the only difference with the previous test + // is that homeWorld is populated since errorPolicy: all + // populates both partial data and error.graphQLErrors + homeWorld: null, + id: "1000", + name: "Luke Skywalker", + }, + { + // homeWorld is populated due to errorPolicy: all + homeWorld: "Alderaan", + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { homeWorld: null, id: "1000", name: "Luke Skywalker" }, + { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + extensions: { + thing1: "foo", + thing2: "bar", + }, + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..d70c095de26 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,1143 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { + ApolloClient, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +test("should handle deferred queries", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists", async () => { + const query = gql` + { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + pending: [ + { id: "0", path: ["greetings", 0] }, + { id: "1", path: ["greetings", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + { + data: { + recipient: { + name: "Bob", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + message: "Hello world", + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + { + message: "Hello again", + __typename: "Greeting", + recipient: { name: "Bob", __typename: "Person" }, + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greetings: [ + { message: "Hello world", __typename: "Greeting" }, + { message: "Hello again", __typename: "Greeting" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries in lists, merging arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + pending: [ + { id: "0", path: ["allProducts", 0, "delivery"] }, + { id: "1", path: ["allProducts", 1, "delivery"] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "0", + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + variables: {}, + }); +}); + +test("should handle deferred queries with fetch policy no-cache", async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { + name: "Alice", + __typename: "Person", + }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { + name: "Alice", + __typename: "Person", + }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + message: "Hello world", + __typename: "Greeting", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle deferred queries with errors returned on the incremental batched result", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + id: "0", + }, + { + data: { + homeWorld: "Alderaan", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + id: "0", + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + { + data: { + homeWorld: "Alderaan", + }, + id: "1", + extensions: { + thing1: "foo", + thing2: "bar", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + // the only difference with the previous test + // is that homeWorld is populated since errorPolicy: all + // populates both partial data and error.graphQLErrors + homeWorld: null, + id: "1000", + name: "Luke Skywalker", + }, + { + // homeWorld is populated due to errorPolicy: all + homeWorld: "Alderaan", + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { homeWorld: null, id: "1000", name: "Luke Skywalker" }, + { homeWorld: "Alderaan", id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + extensions: { + thing1: "foo", + thing2: "bar", + }, + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from deferred queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { __typename: "Greeting", message: "Hello world" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..aad6b5a7618 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx @@ -0,0 +1,775 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..28a65e677f0 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,775 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5ba0dce3d1b..31ea6583555 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -32,10 +32,7 @@ import { NetworkStatus, } from "@apollo/client"; import type { Incremental } from "@apollo/client/incremental"; -import { - Defer20220824Handler, - NotImplementedHandler, -} from "@apollo/client/incremental"; +import { NotImplementedHandler } from "@apollo/client/incremental"; import type { Unmasked } from "@apollo/client/masking"; import { ApolloProvider, @@ -50,7 +47,6 @@ import type { import { actAsync, createClientWrapper, - markAsStreaming, renderAsync, renderHookAsync, setupPaginatedCase, @@ -7137,2863 +7133,6 @@ describe("useSuspenseQuery", () => { expect(client.getObservableQueries().size).toBe(1); }); - it("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it.each([ - "cache-first", - "network-only", - "no-cache", - "cache-and-network", - ])( - 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', - async (fetchPolicy) => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - greeting: { message: "Hello world", __typename: "Greeting" }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - } - ); - - it('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), - { cache, incrementalHandler: new Defer20220824Handler() } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - message: "Hello world", - __typename: "Greeting", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - - // We are intentionally writing partial data to the cache. Supress console - // warnings to avoid unnecessary noise in the test. - { - using _consoleSpy = spyOnConsole("error"); - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - } - - const { result, renders } = await renderSuspenseHook( - () => - useSuspenseQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }), - { cache, link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }); - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - incrementalHandler: new Defer20220824Handler(), - }); - - cache.writeQuery({ - query, - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), - { client } - ); - - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - message: "Hello cached", - __typename: "Greeting", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.loading, - error: undefined, - }); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toStrictEqualTyped([ - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello cached", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.loading, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Cached Alice" }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("suspends deferred queries with lists and properly patches results", async () => { - const query = gql` - query { - greetings { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult({ - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Alice" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - __typename: "Greeting", - recipient: { __typename: "Person", name: "Bob" }, - }, - path: ["greetings", 1], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - recipient: { __typename: "Person", name: "Bob" }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { __typename: "Greeting", message: "Hello world" }, - { __typename: "Greeting", message: "Hello again" }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - { - __typename: "Greeting", - message: "Hello again", - recipient: { __typename: "Person", name: "Bob" }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("suspends queries with deferred fragments in lists and properly merges arrays", async () => { - const query = gql` - query DeferVariation { - allProducts { - delivery { - ...MyFragment @defer - } - sku - id - } - } - - fragment MyFragment on DeliveryEstimates { - estimatedDelivery - fastestDelivery - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult({ - result: { - hasNext: true, - incremental: [ - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 0, "delivery"], - }, - { - data: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - path: ["allProducts", 1, "delivery"], - }, - ], - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - allProducts: [ - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-federation", - sku: "federation", - }, - { - __typename: "Product", - delivery: { - __typename: "DeliveryEstimates", - estimatedDelivery: "6/25/2021", - fastestDelivery: "6/24/2021", - }, - id: "apollo-studio", - sku: "studio", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - }); - - it("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { - const query = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { client } - ); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let refetchPromise: Promise>; - await actAsync(async () => { - refetchPromise = result.current.refetch(); - }); - - link.simulateResult({ - result: { - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(refetchPromise!).resolves.toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - }); - - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("incrementally renders data returned after skipping a deferred query", async () => { - const query = gql` - query { - greeting { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, rerenderAsync, renders } = await renderSuspenseHook( - ({ skip }) => useSuspenseQuery(query, { skip }), - { client, initialProps: { skip: true } } - ); - - expect(result.current).toStrictEqualTyped({ - data: undefined, - dataState: "empty", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - await rerenderAsync({ skip: false }); - - expect(renders.suspenseCount).toBe(1); - - link.simulateResult({ - result: { - data: { greeting: { __typename: "Greeting", message: "Hello world" } }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: undefined, - dataState: "empty", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greeting: { - __typename: "Greeting", - message: "Hello world", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - // TODO: This test is a bit of a lie. `fetchMore` should incrementally - // rerender when using `@defer` but there is currently a bug in the core - // implementation that prevents updates until the final result is returned. - // This test reflects the behavior as it exists today, but will need - // to be updated once the core bug is fixed. - // - // NOTE: A duplicate it.failng test has been added right below this one with - // the expected behavior added in (i.e. the commented code in this test). Once - // the core bug is fixed, this test can be removed in favor of the other test. - // - // https://github.com/apollographql/apollo-client/issues/11034 - it("rerenders data returned by `fetchMore` for a deferred query", async () => { - const query = gql` - query ($offset: Int) { - greetings(offset: $offset) { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - greetings: offsetLimitPagination(), - }, - }, - }, - }); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { variables: { offset: 0 } }), - { client } - ); - - link.simulateResult({ - result: { - data: { - greetings: [{ __typename: "Greeting", message: "Hello world" }], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let fetchMorePromise: Promise>; - await actAsync(() => { - fetchMorePromise = result.current.fetchMore({ variables: { offset: 1 } }); - }); - - link.simulateResult({ - result: { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }, - hasNext: true, - }, - }); - - // TODO: Re-enable once the core bug is fixed - // await waitFor(() => { - // expect(result.current).toStrictEqualTyped({ - // data: { - // greetings: [ - // { - // __typename: 'Greeting', - // message: 'Hello world', - // recipient: { - // __typename: 'Person', - // name: 'Alice', - // }, - // }, - // { - // __typename: 'Greeting', - // message: 'Goodbye', - // }, - // ], - // }, - // dataState: "streaming", - // networkStatus: NetworkStatus.streaming, - // error: undefined, - // }); - // }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(fetchMorePromise!).resolves.toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - }); - - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - // TODO: Re-enable when the core `fetchMore` bug is fixed - // { - // data: { - // greetings: [ - // { - // __typename: 'Greeting', - // message: 'Hello world', - // recipient: { - // __typename: 'Person', - // name: 'Alice', - // }, - // }, - // { - // __typename: 'Greeting', - // message: 'Goodbye', - // }, - // ], - // }, - // dataState: "streaming", - // networkStatus: NetworkStatus.streaming, - // error: undefined, - // }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - // TODO: This is a duplicate of the test above, but with the expected behavior - // added (hence the `it.failing`). Remove the previous test once issue #11034 - // is fixed. - // - // https://github.com/apollographql/apollo-client/issues/11034 - it.failing( - "incrementally rerenders data returned by a `fetchMore` for a deferred query", - async () => { - const query = gql` - query ($offset: Int) { - greetings(offset: $offset) { - message - ... @defer { - recipient { - name - } - } - } - } - `; - - const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - greetings: offsetLimitPagination(), - }, - }, - }, - }); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { variables: { offset: 0 } }), - { client } - ); - - link.simulateResult({ - result: { - data: { - greetings: [{ __typename: "Greeting", message: "Hello world" }], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - let fetchMorePromise: Promise>; - await actAsync(() => { - fetchMorePromise = result.current.fetchMore({ - variables: { offset: 1 }, - }); - }); - - link.simulateResult({ - result: { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Bob", __typename: "Person" }, - }, - path: ["greetings", 0], - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(fetchMorePromise!).resolves.toEqual({ - data: { - greetings: [ - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - loading: false, - networkStatus: NetworkStatus.ready, - error: undefined, - }); - - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: markAsStreaming({ - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - }, - ], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - greetings: [ - { - __typename: "Greeting", - message: "Hello world", - recipient: { - __typename: "Person", - name: "Alice", - }, - }, - { - __typename: "Greeting", - message: "Goodbye", - recipient: { - __typename: "Person", - name: "Bob", - }, - }, - ], - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - } - ); - - it("throws network errors returned by deferred queries", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - error: new Error("Could not fetch"), - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(Error); - expect(error).toEqual(new Error("Could not fetch")); - }); - - it("throws graphql errors returned by deferred queries", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - result: { - errors: [new GraphQLError("Could not fetch greeting")], - }, - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - errors: [{ message: "Could not fetch greeting" }], - }) - ); - }); - - it("throws errors returned by deferred queries that include partial data", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { - link, - incrementalHandler: new Defer20220824Handler(), - } - ); - - link.simulateResult({ - result: { - data: { greeting: null }, - errors: [new GraphQLError("Could not fetch greeting")], - }, - }); - - await waitFor(() => expect(renders.errorCount).toBe(1)); - - expect(renders.errors.length).toBe(1); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - data: { greeting: null }, - errors: [{ message: "Could not fetch greeting" }], - }) - ); - }); - - it("discards partial data and throws errors returned in incremental chunks", async () => { - using _consoleSpy = spyOnConsole("error"); - - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - // This chunk is ignored since errorPolicy `none` throws away partial - // data - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(renders.errorCount).toBe(1); - }); - - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - ]); - - const [error] = renders.errors; - - expect(error).toBeInstanceOf(CombinedGraphQLErrors); - expect(error).toEqual( - new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }) - ); - }); - - it("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - // Unlike the default (errorPolicy = `none`), this data will be - // added to the final result - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }, - ]); - }); - - it("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "ignore" }), - { link, incrementalHandler: new Defer20220824Handler() } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(3 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - }, - { - id: "1003", - name: "Leia Organa", - }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { - id: "1000", - name: "Luke Skywalker", - homeWorld: null, - }, - { - id: "1003", - name: "Leia Organa", - homeWorld: "Alderaan", - }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - - it("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { - const query = gql` - query { - hero { - name - heroFriends { - id - name - ... @defer { - homeWorld - } - } - } - } - `; - - const cache = new InMemoryCache(); - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - link, - cache, - incrementalHandler: new Defer20220824Handler(), - }); - - const { result, renders } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { client } - ); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - errors: [ - new GraphQLError( - "homeWorld for character with ID 1000 could not be fetched.", - { path: ["hero", "heroFriends", 0, "homeWorld"] } - ), - ], - data: { - homeWorld: null, - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }); - }); - - let refetchPromise: Promise>; - await actAsync(async () => { - refetchPromise = result.current.refetch(); - }); - - link.simulateResult({ - result: { - data: { - hero: { - name: "R2-D2", - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - }, - }, - hasNext: true, - }, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - }); - - link.simulateResult( - { - result: { - incremental: [ - { - path: ["hero", "heroFriends", 0], - data: { - homeWorld: "Alderaan", - }, - }, - { - path: ["hero", "heroFriends", 1], - data: { - homeWorld: "Alderaan", - }, - }, - ], - hasNext: false, - }, - }, - true - ); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await expect(refetchPromise!).resolves.toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - }); - - cache.updateQuery({ query }, (data) => ({ - hero: { - ...data.hero, - name: "C3PO", - }, - })); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "C3PO", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - expect(renders.count).toBe(7 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker" }, - { id: "1003", name: "Leia Organa" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.error, - error: new CombinedGraphQLErrors({ - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - errors: [ - { - message: - "homeWorld for character with ID 1000 could not be fetched.", - path: ["hero", "heroFriends", 0, "homeWorld"], - }, - ], - }), - }, - { - data: markAsStreaming({ - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: null }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "R2-D2", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: { - hero: { - heroFriends: [ - { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, - { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, - ], - name: "C3PO", - }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); - }); - it("can subscribe to subscriptions and react to cache updates via `subscribeToMore`", async () => { interface SubscriptionData { greetingUpdated: string; diff --git a/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx new file mode 100644 index 00000000000..4f5aac41f24 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx @@ -0,0 +1,2261 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { delay, of, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDefer20220824, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +test("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello cached", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends deferred queries with lists and properly patches results", async () => { + const query = gql` + query { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Bob" }, + }, + path: ["greetings", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + recipient: { __typename: "Person", name: "Bob" }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends queries with deferred fragments in lists and properly merges arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); +}); + +test("incrementally renders data returned after skipping a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greeting"], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a deferred query", async () => { + const query = gql` + query ($offset: Int) { + greetings(offset: $offset) { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 1 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + path: ["greetings", 0], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk } = mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + errors: [{ message: "Could not fetch greeting" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws errors returned by deferred queries that include partial data", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // This chunk is ignored since errorPolicy `none` throws away partial + // data + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // Unlike the default (errorPolicy = `none`), this data will be + // added to the final result + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDefer20220824(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + path: ["hero", "heroFriends", 0], + data: { + homeWorld: "Alderaan", + }, + }, + { + path: ["hero", "heroFriends", 1], + data: { + homeWorld: "Alderaan", + }, + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + hero: { + ...data.hero, + name: "C3PO", + }, + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "C3PO", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..f928f171c9f --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,2311 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { delay, of, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + createClientWrapper, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +test("suspends deferred queries until initial chunk loads then streams in data as it loads", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends deferred queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { message: "Hello world", __typename: "Greeting" }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend deferred queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello world", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + const cache = new InMemoryCache(); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + } + + const client = new ApolloClient({ + cache, + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + greeting: { + __typename: "Greeting", + message: "Hello cached", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + message: "Hello cached", + __typename: "Greeting", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Cached Alice" }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends deferred queries with lists and properly patches results", async () => { + const query = gql` + query { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }, + pending: [ + { id: "0", path: ["greetings", 0] }, + { id: "1", path: ["greetings", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { __typename: "Greeting", message: "Hello world" }, + { __typename: "Greeting", message: "Hello again" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Alice" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + __typename: "Greeting", + recipient: { __typename: "Person", name: "Bob" }, + }, + id: "1", + }, + ], + completed: [{ id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + { + __typename: "Greeting", + message: "Hello again", + recipient: { __typename: "Person", name: "Bob" }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends queries with deferred fragments in lists and properly merges arrays", async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + pending: [ + { id: "0", path: ["allProducts", 0, "delivery"] }, + { id: "1", path: ["allProducts", 1, "delivery"] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "0", + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "1", + }, + ], + completed: [{ id: "0" }, { id: "1" }], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + }, + }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + }, + }); +}); + +test("incrementally renders data returned after skipping a deferred query", async () => { + const query = gql` + query { + greeting { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { __typename: "Greeting", message: "Hello world" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { + __typename: "Greeting", + message: "Hello world", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a deferred query", async () => { + const query = gql` + query ($offset: Int) { + greetings(offset: $offset) { + message + ... @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: httpLink, + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }, + pending: [{ id: "0", path: ["greetings", 0] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [{ __typename: "Greeting", message: "Hello world" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 1 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }, + pending: [{ id: "0", path: ["greetings", 0] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Bob", __typename: "Person" }, + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Hello world", + recipient: { + __typename: "Person", + name: "Alice", + }, + }, + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + greetings: [ + { + __typename: "Greeting", + message: "Goodbye", + recipient: { + __typename: "Person", + name: "Bob", + }, + }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by deferred queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: null, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws errors returned by deferred queries that include partial data", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return of({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { greeting: null }, + errors: [{ message: "Could not fetch greeting" }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // This chunk is ignored since errorPolicy `none` throws away partial + // data + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + // Unlike the default (errorPolicy = `none`), this data will be + // added to the final result + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + }, + { + id: "1003", + name: "Leia Organa", + }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker", + homeWorld: null, + }, + { + id: "1003", + name: "Leia Organa", + homeWorld: "Alderaan", + }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a deferred query when `errorPolicy` is `all`", async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + data: { + homeWorld: null, + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + errors: [ + { + message: + "homeWorld for character with ID 1000 could not be fetched.", + path: ["hero", "heroFriends", 0, "homeWorld"], + }, + ], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { id: "1000", name: "Luke Skywalker" }, + { id: "1003", name: "Leia Organa" }, + ], + }, + }, + pending: [ + { id: "0", path: ["hero", "heroFriends", 0] }, + { id: "1", path: ["hero", "heroFriends", 1] }, + ], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: null }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + id: "0", + data: { + homeWorld: "Alderaan", + }, + }, + { + id: "1", + data: { + homeWorld: "Alderaan", + }, + }, + ], + completed: [{ id: "0" }, { id: "1" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "R2-D2", + }, + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + hero: { + ...data.hero, + name: "C3PO", + }, + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + hero: { + heroFriends: [ + { id: "1000", name: "Luke Skywalker", homeWorld: "Alderaan" }, + { id: "1003", name: "Leia Organa", homeWorld: "Alderaan" }, + ], + name: "C3PO", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..a14e4145350 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -0,0 +1,1533 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke (refetch)" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..7a638011913 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,1561 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + using _consoleSpy = spyOnConsole("error"); + cache.writeQuery({ + query, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: { + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke (refetch)" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// https://github.com/apollographql/apollo-client/issues/11034 +test("incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + friendList: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [{ message: "Could not get friend", path: ["friendList"] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject>(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject | Friend>; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + const iterable = asyncIterableSubject | Friend>(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index e7378595202..14c23ec398f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -160,7 +160,18 @@ export declare namespace useMutation { TData = unknown, TVariables extends OperationVariables = OperationVariables, TCache extends ApolloCache = ApolloCache, - > = Options; + > = Options & { + /** + * {@inheritDoc @apollo/client!MutationOptionsDocumentation#context:member} + * + * @remarks + * When provided as a callback function, the function is called with the + * value of `context` provided to the `useMutation` hook. + */ + context?: + | DefaultContext + | ((hookContext: DefaultContext | undefined) => DefaultContext); + }; export namespace DocumentationTypes { /** {@inheritDoc @apollo/client/react!useMutation:function(1)} */ @@ -271,6 +282,10 @@ export function useMutation< const { options, mutation } = ref.current; const baseOptions = { ...options, mutation }; const client = executeOptions.client || ref.current.client; + const context = + typeof executeOptions.context === "function" ? + executeOptions.context(options?.context) + : executeOptions.context; if (!ref.current.result.loading && ref.current.isMounted) { setResult( @@ -285,7 +300,10 @@ export function useMutation< } const mutationId = ++ref.current.mutationId; - const clientOptions = mergeOptions(baseOptions, executeOptions as any); + const clientOptions = mergeOptions(baseOptions, { + ...executeOptions, + context, + } as any); return preventUnhandledRejection( client diff --git a/src/react/internal/cache/QueryReference.ts b/src/react/internal/cache/QueryReference.ts index cef2a7f6ec2..e02c76303f9 100644 --- a/src/react/internal/cache/QueryReference.ts +++ b/src/react/internal/cache/QueryReference.ts @@ -186,6 +186,7 @@ export class InternalQueryReference< public promise!: QueryRefPromise; + private queue: QueryRefPromise | undefined; private subscription!: Subscription; private listeners = new Set>(); private autoDisposeTimeoutId?: NodeJS.Timeout; @@ -335,6 +336,11 @@ export class InternalQueryReference< listen(listener: Listener) { this.listeners.add(listener); + if (this.queue) { + this.deliver(this.queue); + this.queue = undefined; + } + return () => { this.listeners.delete(listener); }; @@ -412,6 +418,18 @@ export class InternalQueryReference< } private deliver(promise: QueryRefPromise) { + // Maintain a queue of the last item we tried to deliver so that we can + // deliver it as soon as we get the first listener. This helps in cases such + // as `@stream` where React may render a component and incremental results + // are loaded in between when the component renders and effects are run. If + // effects are run after the incremntal chunks are delivered, we'll have + // rendered a stale value. The queue ensures we can deliver the most + // up-to-date value as soon as the component is ready to listen for new + // values. + if (this.listeners.size === 0) { + this.queue = promise; + } + this.listeners.forEach((listener) => listener(promise)); } diff --git a/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx index f05defde306..023f3e9ffc3 100644 --- a/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx +++ b/src/react/query-preloader/__tests__/createQueryPreloader.test.tsx @@ -25,7 +25,6 @@ import { InMemoryCache, NetworkStatus, } from "@apollo/client"; -import { Defer20220824Handler } from "@apollo/client/incremental"; import type { PreloadedQueryRef, QueryRef } from "@apollo/client/react"; import { ApolloProvider, @@ -33,7 +32,7 @@ import { useReadQuery, } from "@apollo/client/react"; import { unwrapQueryRef } from "@apollo/client/react/internal"; -import { MockLink, MockSubscriptionLink } from "@apollo/client/testing"; +import { MockLink } from "@apollo/client/testing"; import type { MaskedVariablesCaseData, SimpleCaseData, @@ -41,7 +40,6 @@ import type { } from "@apollo/client/testing/internal"; import { createClientWrapper, - markAsStreaming, renderHookAsync, setupMaskedVariablesCase, setupSimpleCase, @@ -1806,97 +1804,6 @@ test("does not suspend and returns partial data when `returnPartialData` is `tru } }); -test("suspends deferred queries until initial chunk loads then rerenders with deferred data", async () => { - const query = gql` - query { - greeting { - message - ... on Greeting @defer { - recipient { - name - } - } - } - } - `; - - const link = new MockSubscriptionLink(); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - incrementalHandler: new Defer20220824Handler(), - }); - - const preloadQuery = createQueryPreloader(client); - const queryRef = preloadQuery(query); - - using _disabledAct = disableActEnvironment(); - const { renderStream } = await renderDefaultTestApp({ client, queryRef }); - - { - const { renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["App", "SuspenseFallback"]); - } - - link.simulateResult({ - result: { - data: { greeting: { message: "Hello world", __typename: "Greeting" } }, - hasNext: true, - }, - }); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); - expect(snapshot.result).toStrictEqualTyped({ - data: markAsStreaming({ - greeting: { message: "Hello world", __typename: "Greeting" }, - }), - dataState: "streaming", - error: undefined, - networkStatus: NetworkStatus.streaming, - }); - } - - link.simulateResult( - { - result: { - incremental: [ - { - data: { - recipient: { name: "Alice", __typename: "Person" }, - __typename: "Greeting", - }, - path: ["greeting"], - }, - ], - hasNext: false, - }, - }, - true - ); - - { - const { snapshot, renderedComponents } = await renderStream.takeRender(); - - expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); - expect(snapshot.result).toStrictEqualTyped({ - data: { - greeting: { - __typename: "Greeting", - message: "Hello world", - recipient: { __typename: "Person", name: "Alice" }, - }, - }, - dataState: "complete", - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } -}); - test("masks result when dataMasking is `true`", async () => { const { query, mocks } = setupMaskedVariablesCase(); const client = new ApolloClient({ diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx new file mode 100644 index 00000000000..024033c91ff --- /dev/null +++ b/src/react/query-preloader/__tests__/createQueryPreloader/defer20220824.test.tsx @@ -0,0 +1,169 @@ +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { DataState } from "@apollo/client"; +import { ApolloClient, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { + ApolloProvider, + createQueryPreloader, + useReadQuery, +} from "@apollo/client/react"; +import { + markAsStreaming, + mockDefer20220824, +} from "@apollo/client/testing/internal"; + +async function renderDefaultTestApp< + TData, + TStates extends DataState["dataState"] = "complete" | "streaming", +>({ + client, + queryRef, +}: { + client: ApolloClient; + queryRef: QueryRef; +}) { + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + error: null as Error | null, + }, + }); + + function ReadQueryHook() { + useTrackRenders({ name: "ReadQueryHook" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + return

Loading

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

Loading

; + } + + function ErrorFallback({ error }: { error: Error }) { + useTrackRenders({ name: "ErrorFallback" }); + renderStream.mergeSnapshot({ error }); + + return null; + } + + function App() { + useTrackRenders({ name: "App" }); + + return ( + + }> + + + + ); + } + + const utils = await renderStream.render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + function rerender() { + return utils.rerender(); + } + + return { ...utils, rerender, renderStream }; +} + +test("suspends deferred queries until initial chunk loads then rerenders with deferred data", async () => { + const query = gql` + query { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const { httpLink, enqueueInitialChunk, enqueueSubsequentChunk } = + mockDeferStreamGraphQL17Alpha9(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "SuspenseFallback"]); + } + + enqueueInitialChunk({ + data: { greeting: { message: "Hello world", __typename: "Greeting" } }, + pending: [{ id: "0", path: ["greeting"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + greeting: { message: "Hello world", __typename: "Greeting" }, + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + enqueueSubsequentChunk({ + incremental: [ + { + data: { + recipient: { name: "Alice", __typename: "Person" }, + __typename: "Greeting", + }, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["ReadQueryHook"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + greeting: { + __typename: "Greeting", + message: "Hello world", + recipient: { __typename: "Person", name: "Alice" }, + }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } +}); diff --git a/src/testing/internal/asyncIterableSubject.ts b/src/testing/internal/asyncIterableSubject.ts new file mode 100644 index 00000000000..9085dd149b4 --- /dev/null +++ b/src/testing/internal/asyncIterableSubject.ts @@ -0,0 +1,16 @@ +import { Subject } from "rxjs"; + +export function asyncIterableSubject() { + const subject = new Subject(); + + const stream = new ReadableStream({ + start: (controller) => { + subject.subscribe({ + next: (value) => controller.enqueue(value), + complete: () => controller.close(), + }); + }, + }); + + return { subject, stream }; +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts new file mode 100644 index 00000000000..eeba9cde67c --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts @@ -0,0 +1,36 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha2"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha2"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha2( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {} +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts new file mode 100644 index 00000000000..8285297367b --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts @@ -0,0 +1,38 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha9"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha9"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha9( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution?: boolean +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index 1ebe8234c9c..de686fc955d 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,12 +23,15 @@ export { } from "./scenarios/index.js"; export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.js"; +export { asyncIterableSubject } from "./asyncIterableSubject.js"; +export { executeSchemaGraphQL17Alpha2 } from "./incremental/executeSchemaGraphQL17Alpha2.js"; +export { executeSchemaGraphQL17Alpha9 } from "./incremental/executeSchemaGraphQL17Alpha9.js"; +export { promiseWithResolvers } from "./promiseWithResolvers.js"; export { renderAsync } from "./rtl/renderAsync.js"; export { renderHookAsync } from "./rtl/renderHookAsync.js"; -export { - mockDeferStream, - mockMultipartSubscriptionStream, -} from "./incremental.js"; +export { mockDefer20220824 } from "./multipart/mockDefer20220824.js"; +export { mockDeferStreamGraphQL17Alpha9 } from "./multipart/mockDeferStreamGraphql17Alpha9.js"; +export { mockMultipartSubscriptionStream } from "./multipart/mockMultipartSubscriptionStream.js"; export { resetApolloContext } from "./resetApolloContext.js"; export { createOperationWithDefaultContext, @@ -36,3 +39,6 @@ export { } from "./link.js"; export { markAsStreaming } from "./markAsStreaming.js"; export { wait } from "./wait.js"; + +export { friendListSchemaGraphQL17Alpha2 } from "./schemas/friendList.graphql17Alpha2.js"; +export { friendListSchemaGraphQL17Alpha9 } from "./schemas/friendList.graphql17Alpha9.js"; diff --git a/src/testing/internal/multipart/mockDefer20220824.ts b/src/testing/internal/multipart/mockDefer20220824.ts new file mode 100644 index 00000000000..67afe6636d7 --- /dev/null +++ b/src/testing/internal/multipart/mockDefer20220824.ts @@ -0,0 +1,47 @@ +import type { + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLFormattedError, +} from "graphql-17-alpha2"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockDefer20220824< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + >({ + responseHeaders: new Headers({ + "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', + }), + }); + return { + httpLink, + enqueueInitialChunk( + chunk: FormattedInitialIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueSubsequentChunk( + chunk: FormattedSubsequentIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueErrorChunk(errors: GraphQLFormattedError[]) { + enqueue( + { + hasNext: true, + incremental: [ + { + errors, + }, + ], + }, + true + ); + }, + }; +} diff --git a/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts b/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts new file mode 100644 index 00000000000..9532b1b57eb --- /dev/null +++ b/src/testing/internal/multipart/mockDeferStreamGraphql17Alpha9.ts @@ -0,0 +1,33 @@ +import type { + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha9"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockDeferStreamGraphQL17Alpha9< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + >({ + responseHeaders: new Headers({ + "Content-Type": 'multipart/mixed; boundary="-"', + }), + }); + return { + httpLink, + enqueueInitialChunk( + chunk: FormattedInitialIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueSubsequentChunk( + chunk: FormattedSubsequentIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + }; +} diff --git a/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts b/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts new file mode 100644 index 00000000000..73e29c1a9cc --- /dev/null +++ b/src/testing/internal/multipart/mockMultipartSubscriptionStream.ts @@ -0,0 +1,36 @@ +import type { ApolloPayloadResult } from "@apollo/client"; + +import { mockMultipartStream } from "./utils.js"; + +export function mockMultipartSubscriptionStream< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockMultipartStream< + ApolloPayloadResult + >({ + responseHeaders: new Headers({ + "Content-Type": "multipart/mixed", + }), + }); + + enqueueHeartbeat(); + + function enqueueHeartbeat() { + enqueue({} as any, true); + } + + return { + httpLink, + enqueueHeartbeat, + enqueuePayloadResult( + payload: ApolloPayloadResult["payload"], + hasNext = true + ) { + enqueue({ payload }, hasNext); + }, + enqueueProtocolErrors(errors: ApolloPayloadResult["errors"]) { + enqueue({ payload: null, errors }, false); + }, + }; +} diff --git a/src/testing/internal/incremental.ts b/src/testing/internal/multipart/utils.ts similarity index 53% rename from src/testing/internal/incremental.ts rename to src/testing/internal/multipart/utils.ts index a457b2189ff..70b9bae52d2 100644 --- a/src/testing/internal/incremental.ts +++ b/src/testing/internal/multipart/utils.ts @@ -4,18 +4,11 @@ import { TransformStream, } from "node:stream/web"; -import type { - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, - GraphQLFormattedError, -} from "graphql-17-alpha2"; - -import type { ApolloPayloadResult } from "@apollo/client"; import { HttpLink } from "@apollo/client/link/http"; const hasNextSymbol = Symbol("hasNext"); -function mockIncrementalStream({ +export function mockMultipartStream({ responseHeaders, }: { responseHeaders: Headers; @@ -108,76 +101,3 @@ function mockIncrementalStream({ close, }; } - -export function mockDeferStream< - TData = Record, - TExtensions = Record, ->() { - const { httpLink, enqueue } = mockIncrementalStream< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - >({ - responseHeaders: new Headers({ - "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', - }), - }); - return { - httpLink, - enqueueInitialChunk( - chunk: FormattedInitialIncrementalExecutionResult - ) { - enqueue(chunk, chunk.hasNext); - }, - enqueueSubsequentChunk( - chunk: FormattedSubsequentIncrementalExecutionResult - ) { - enqueue(chunk, chunk.hasNext); - }, - enqueueErrorChunk(errors: GraphQLFormattedError[]) { - enqueue( - { - hasNext: true, - incremental: [ - { - errors, - }, - ], - }, - true - ); - }, - }; -} - -export function mockMultipartSubscriptionStream< - TData = Record, - TExtensions = Record, ->() { - const { httpLink, enqueue } = mockIncrementalStream< - ApolloPayloadResult - >({ - responseHeaders: new Headers({ - "Content-Type": "multipart/mixed", - }), - }); - - enqueueHeartbeat(); - - function enqueueHeartbeat() { - enqueue({} as any, true); - } - - return { - httpLink, - enqueueHeartbeat, - enqueuePayloadResult( - payload: ApolloPayloadResult["payload"], - hasNext = true - ) { - enqueue({ payload }, hasNext); - }, - enqueueProtocolErrors(errors: ApolloPayloadResult["errors"]) { - enqueue({ payload: null, errors }, false); - }, - }; -} diff --git a/src/testing/internal/promiseWithResolvers.ts b/src/testing/internal/promiseWithResolvers.ts new file mode 100644 index 00000000000..68283719b04 --- /dev/null +++ b/src/testing/internal/promiseWithResolvers.ts @@ -0,0 +1,15 @@ +export function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | Promise) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | Promise) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha2.ts b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts new file mode 100644 index 00000000000..17d59da59a4 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha2"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha2 = new GraphQLSchema({ query }); diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha9.ts b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts new file mode 100644 index 00000000000..4f774afab13 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha9 = new GraphQLSchema({ query }); diff --git a/src/utilities/internal/DeepMerger.ts b/src/utilities/internal/DeepMerger.ts index ebbcce20660..91d28780387 100644 --- a/src/utilities/internal/DeepMerger.ts +++ b/src/utilities/internal/DeepMerger.ts @@ -19,12 +19,38 @@ const defaultReconciler: ReconcilerFunction = function ( }; /** @internal */ -export class DeepMerger { +export declare namespace DeepMerger { + export interface Options { + arrayMerge?: DeepMerger.ArrayMergeStrategy; + } + + export type ArrayMergeStrategy = + // Truncate the target array to the source length, then deep merge the array + // items at the same index + | "truncate" + // Combine arrays and deep merge array items for items at the same index. + // This is the default + | "combine"; +} + +/** @internal */ +export class DeepMerger { constructor( - private reconciler: ReconcilerFunction = defaultReconciler as any as ReconcilerFunction + private reconciler: ReconcilerFunction = defaultReconciler as any as ReconcilerFunction, + private options: DeepMerger.Options = {} ) {} public merge(target: any, source: any, ...context: TContextArgs): any { + if ( + Array.isArray(target) && + Array.isArray(source) && + this.options.arrayMerge === "truncate" && + target.length > source.length + ) { + target = target.slice(0, source.length); + this.pastCopies.add(target); + } + if (isNonNullObject(source) && isNonNullObject(target)) { Object.keys(source).forEach((sourceKey) => { if (hasOwnProperty.call(target, sourceKey)) { diff --git a/src/utilities/internal/__tests__/DeepMerger.test.ts b/src/utilities/internal/__tests__/DeepMerger.test.ts index 88d6b05a2ba..01b0361ac83 100644 --- a/src/utilities/internal/__tests__/DeepMerger.test.ts +++ b/src/utilities/internal/__tests__/DeepMerger.test.ts @@ -94,3 +94,34 @@ test("provides optional context to reconciler function", function () { expect(typicalContextValues[0]).toBe(contextObject); expect(typicalContextValues[1]).toBe(contextObject); }); + +test("deep merges each array item keeping length by default", () => { + const target = [{ a: 1, b: { c: 2 } }, { e: 5 }]; + const source = [{ a: 2, b: { c: 2, d: 3 } }]; + + const result = new DeepMerger().merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2, d: 3 } }, { e: 5 }]); +}); + +test("deep merges each array item and truncates source to target length when using truncate arrayMerge", () => { + const target = [{ a: 1, b: { c: 2 } }, { e: 5 }]; + const source = [{ a: 2, b: { c: 2, d: 3 } }]; + + const result = new DeepMerger(undefined, { + arrayMerge: "truncate", + }).merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2, d: 3 } }]); +}); + +test("maintains source length when using truncate arrayMerge when source is longer than target length", () => { + const target = [{ a: 1, b: { c: 2 } }]; + const source = [{ a: 2 }, { e: 2 }]; + + const result = new DeepMerger(undefined, { + arrayMerge: "truncate", + }).merge(target, source); + + expect(result).toEqual([{ a: 2, b: { c: 2 } }, { e: 2 }]); +}); diff --git a/src/utilities/internal/getStoreKeyName.ts b/src/utilities/internal/getStoreKeyName.ts index e8056c246af..0b63ebcce30 100644 --- a/src/utilities/internal/getStoreKeyName.ts +++ b/src/utilities/internal/getStoreKeyName.ts @@ -14,6 +14,7 @@ const KNOWN_DIRECTIVES: string[] = [ "rest", "export", "nonreactive", + "stream", ]; // Default stable JSON.stringify implementation used by getStoreKeyName. Can be diff --git a/tsconfig.json b/tsconfig.json index 7bbdcf7fdcc..578691cbab1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "experimentalDecorators": true, "outDir": "./dist", "rootDir": "./src", - "lib": ["DOM", "ES2023"], + "lib": ["DOM", "dom.asyncIterable", "ES2023"], "types": [ "jest", "node",