diff --git a/.api-reports/api-report-incremental.api.md b/.api-reports/api-report-incremental.api.md index efdd684674c..f5ce7d7230e 100644 --- a/.api-reports/api-report-incremental.api.md +++ b/.api-reports/api-report-incremental.api.md @@ -80,6 +80,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 +202,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/.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/.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..e48d56978cf 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)": 44206, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39060, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33462, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27490 } 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/package-lock.json b/package-lock.json index bd60c4e9976..18dbd11a0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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..18c08e9fdca 100644 --- a/package.json +++ b/package.json @@ -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__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index c7343506bff..9207e712cdd 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", ] `; @@ -362,7 +363,8 @@ Array [ "enableFakeTimers", "executeWithDefaultContext", "markAsStreaming", - "mockDeferStream", + "mockDefer20220824", + "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", "renderAsync", "renderHookAsync", 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/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..36c6ba5b8bb --- /dev/null +++ b/src/core/__tests__/client.watchQuery/defer20220824.test.ts @@ -0,0 +1,165 @@ +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 { + 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); +}); 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..035ce0525df --- /dev/null +++ b/src/core/__tests__/client.watchQuery/deferGraphQL17Alpha9.test.ts @@ -0,0 +1,175 @@ +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 { + 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); +}); diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824.test.ts index f5795710d6b..e412199e2a6 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824.test.ts @@ -28,7 +28,7 @@ import { import { Defer20220824Handler } from "@apollo/client/incremental"; import { markAsStreaming, - mockDeferStream, + mockDefer20220824, ObservableStream, } from "@apollo/client/testing/internal"; @@ -683,7 +683,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 +979,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__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts new file mode 100644 index 00000000000..b61e7d2d4e7 --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -0,0 +1,2678 @@ +import assert from "node:assert"; + +import type { + DocumentNode, + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha9"; +import { + experimentalExecuteIncrementally, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, + Observable, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + 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); +} + +type PromiseOrValue = Promise | T; + +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseOrValue) => void; + reject: (reason?: any) => void; +} { + // these are assigned synchronously within the Promise constructor + let resolve!: (value: T | PromiseOrValue) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function* run( + document: DocumentNode, + rootValue: Record = { hero }, + enableEarlyExecution = false +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + 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 { + yield JSON.parse(JSON.stringify(result)) as FormattedExecutionResult; + } +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return new Observable((observer) => { + void (async () => { + for await (const chunk of run(operation.query, rootValue)) { + observer.next(chunk); + } + observer.complete(); + })(); + }); + }); +} + +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/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts new file mode 100644 index 00000000000..7e4d59af29b --- /dev/null +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -0,0 +1,241 @@ +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[] = []; + + 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); + } + + this.merge(chunk, new DeepMerger()); + + if (hasIncrementalChunks(chunk)) { + for (const incremental of chunk.incremental) { + // TODO: Implement support for `@stream`. For now we will skip handling + // streamed responses + if ("items" in incremental) { + continue; + } + + 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." + ); + + let { data } = incremental; + const path = pending.path.concat(incremental.subPath ?? []); + + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = data; + data = parent as typeof data; + } + + this.merge( + { + data: data as TData, + extensions: incremental.extensions, + errors: incremental.errors, + }, + new DeepMerger() + ); + } + } + + 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, + merger: DeepMerger + ) { + if (normalized.data !== undefined) { + this.data = merger.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"], request.query)) { + const context = request.context ?? {}; + const http = (context.http ??= {}); + http.accept = ["multipart/mixed", ...(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/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/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__/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/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__/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..dcf8e4a32cb --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/defer20220824.test.tsx @@ -0,0 +1,2495 @@ +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, + 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 } + >(); + + 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(); +}); + +// 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 +test("rerenders data returned by `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, + }); + + // TODO: Re-enable once the core bug is fixed + // { + // 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, + // }); + // } + + await wait(0); + 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(); +}); + +// 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 { 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..51770ed06e9 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/deferGraphQL17Alpha9.test.tsx @@ -0,0 +1,2551 @@ +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, + 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 } + >(); + + 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(); +}); + +// 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 +test("rerenders data returned by `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, + }); + + // TODO: Re-enable once the core bug is fixed + // { + // 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, + // }); + // } + + await wait(0); + 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(); +}); + +// 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 { 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/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/index.ts b/src/testing/internal/index.ts index 1ebe8234c9c..37fad789108 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -25,10 +25,9 @@ export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.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, 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); - }, - }; -}