diff --git a/.api-reports/api-report-incremental.api.md b/.api-reports/api-report-incremental.api.md index f5ce7d7230e..523c2a8b193 100644 --- a/.api-reports/api-report-incremental.api.md +++ b/.api-reports/api-report-incremental.api.md @@ -25,19 +25,30 @@ namespace Defer20220824Handler { return: Defer20220824Handler.Chunk>; } // (undocumented) - type IncrementalDeferPayload> = { - data?: TData | null | undefined; + type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; // (undocumented) + type IncrementalResult> = IncrementalDeferResult | IncrementalStreamResult; + // (undocumented) + type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + // (undocumented) type InitialResult> = { data?: TData | null | undefined; errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; // (undocumented) type SubsequentResult> = { @@ -45,7 +56,7 @@ namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; // (undocumented) interface TypeOverrides { diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index f4329853077..d1055bf60ca 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -449,7 +449,7 @@ export type VariablesOption = {} extends // Warnings were encountered during analysis: // -// src/utilities/internal/getStoreKeyName.ts:88:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts +// src/utilities/internal/getStoreKeyName.ts:89:1 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/six-islands-drum.md b/.changeset/six-islands-drum.md new file mode 100644 index 00000000000..e540e2b375c --- /dev/null +++ b/.changeset/six-islands-drum.md @@ -0,0 +1,8 @@ +--- +"@apollo/client": minor +--- + +Add support for the `@stream` directive on both the `Defer20220824Handler` and the `GraphQL17Alpha2Handler`. + +> [!NOTE] +> The implementations of `@stream` differ in the delivery of incremental results between the different GraphQL spec versions. If you upgrading from the older format to the newer format, expect the timing of some incremental results to change. diff --git a/.size-limits.json b/.size-limits.json index e48d56978cf..e4f01b44776 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "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 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 44194, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39041, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33526, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27519 } diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 9207e712cdd..2dd66642204 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -357,15 +357,21 @@ Array [ "ObservableStream", "actAsync", "addDelayToMocks", + "asyncIterableSubject", "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", "enableFakeTimers", + "executeSchemaGraphQL17Alpha2", + "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", + "friendListSchemaGraphQL17Alpha2", + "friendListSchemaGraphQL17Alpha9", "markAsStreaming", "mockDefer20220824", "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "promiseWithResolvers", "renderAsync", "renderHookAsync", "resetApolloContext", diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 2a16c513bc5..26eedf301bf 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1042,6 +1042,49 @@ describe("Cache", () => { }); } ); + + it("does not write @stream directive as part of the cache key", () => { + const cache = new InMemoryCache(); + + cache.writeQuery({ + data: { + list: [{ __typename: "Item", id: "1", value: 1 }], + }, + query: gql` + query { + list @stream(initialCount: 1) { + id + value + } + } + `, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + list: [{ __ref: "Item:1" }], + }, + "Item:1": { __typename: "Item", id: "1", value: 1 }, + }); + + // We should be able to read the list without the `@stream` directive and + // get back results + expect( + cache.readQuery({ + query: gql` + query { + list { + id + value + } + } + `, + }) + ).toStrictEqualTyped({ + list: [{ __typename: "Item", id: "1", value: 1 }], + }); + }); }); describe("writeFragment", () => { diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts new file mode 100644 index 00000000000..01ab8f1f78a --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -0,0 +1,881 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + mockDefer20220824, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDefer20220824(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [{ __typename: "Friend", id: "2", name: "Han" }] as any, + path: ["friendList", 1], + }, + { + items: [{ __typename: "Friend", id: "3", name: "Leia" }] as any, + path: ["friendList", 2], + }, + ], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +// TODO: Determine how to handle this case. This emits an error for the item at +// index 1 because it is non-null, but also emits the friend at index 2 to add +// to the array. This leaves us in a bit of an impossible state as +// we can't really set nonNullFriendList[1] to `null`, otherwise we violate the +// schema. Should we stop processing results if we recieve an `items: null` from +// the server indicating an error was thrown to the nearest boundary? +it.failing( + "handles errors thrown due to null returned in non-null list items after initialCount is reached", + async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); + } +); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(null); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts new file mode 100644 index 00000000000..bdcd108a54e --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -0,0 +1,885 @@ +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +test("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createLink({ scalarList: ["apple", "banana", "orange"] }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles streamed multi-dimensional lists", async () => { + const client = new ApolloClient({ + link: createLink({ + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query ScalarListQuery { + scalarListList @stream(initialCount: 1) + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("merges cache updates that happen concurrently", async () => { + const stream = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + link: stream.httpLink, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + stream.enqueueInitialChunk({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + pending: [{ id: "0", path: ["friendList"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + client.cache.writeFragment({ + id: "Friend:1", + fragment: gql` + fragment FriendName on Friend { + name + } + `, + data: { + name: "Jedi", + }, + }); + + stream.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { + __typename: "Friend", + id: "1", + name: "Jedi", // updated from cache + }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("handles errors from items before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors from items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + friendList: () => + friends.map((friend, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + + return Promise.resolve(friend); + }), + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "bad", path: ["friendList", 1] }], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles final chunk without incremental value", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown before initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 2) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: null, + }, + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles errors thrown after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { friendList: [{ __typename: "Friend", id: "1", name: "Luke" }] }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles errors thrown due to null returned in non-null list items after initialCount is reached", async () => { + const client = new ApolloClient({ + link: createLink({ + nonNullFriendList: () => [friends[0], null, friends[1]], + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +it("handles stream when in parent deferred fragment", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveSlowField("slow"); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + nestedObject: { + __typename: "NestedObject", + scalarField: "slow", + nestedFriendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }), + }); + + await expect(observableStream).not.toEmitAnything(); +}); + +test("handles @defer inside @stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const client = new ApolloClient({ + link: createLink({ + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query { + friendList @stream { + ...NameFragment @defer + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + resolveIterableCompletion(null); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + }), + }); + + resolveSlowField("Han"); + + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + ], + }), + dataState: "streaming", + }), + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts similarity index 91% rename from src/incremental/handlers/__tests__/defer20220824.test.ts rename to src/incremental/handlers/__tests__/defer20220824/defer.test.ts index e412199e2a6..7ed32d8c991 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -1,13 +1,6 @@ import assert from "node:assert"; -import type { - DocumentNode, - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha2"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -15,7 +8,9 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha2"; +import { from } from "rxjs"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -23,10 +18,10 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, mockDefer20220824, ObservableStream, @@ -35,7 +30,7 @@ import { import { hasIncrementalChunks, // eslint-disable-next-line local-rules/no-relative-imports -} from "../defer20220824.js"; +} from "../../defer20220824.js"; // This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: // https://github.com/graphql/graphql-js/blob/364cd71d1a26eb6f62661efd7fa399e91332d30d/src/execution/__tests__/defer-test.ts @@ -105,41 +100,12 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -async function* run( - document: DocumentNode -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult, - FormattedExecutionResult | void -> { - const result = await experimentalExecuteIncrementally({ - schema, - document, - rootValue: {}, - }); - if ("initialResult" in result) { - yield JSON.parse( - JSON.stringify(result.initialResult) - ) as FormattedInitialIncrementalExecutionResult; - for await (const incremental of result.subsequentResults) { - yield JSON.parse( - JSON.stringify(incremental) - ) as FormattedSubsequentIncrementalExecutionResult; - } - } else { - return result; - } +function run(query: DocumentNode) { + return executeSchemaGraphQL17Alpha2(schema, query); } const schemaLink = new ApolloLink((operation) => { - return new Observable((observer) => { - void (async () => { - for await (const chunk of run(operation.query)) { - observer.next(chunk); - } - observer.complete(); - })(); - }); + return from(run(operation.query)); }); describe("graphql-js test cases", () => { @@ -166,7 +132,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -180,7 +146,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -234,7 +200,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -244,7 +210,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -275,7 +241,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: {}, @@ -285,7 +251,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -329,7 +295,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -344,7 +310,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -380,7 +346,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -393,7 +359,7 @@ describe("graphql-js test cases", () => { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -433,7 +399,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -443,7 +409,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -482,7 +448,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -492,7 +458,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -563,7 +529,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { hero: { id: "1" } }, @@ -573,7 +539,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { @@ -589,7 +555,7 @@ describe("graphql-js test cases", () => { { const { value: chunk, done } = (await incoming.next())!; assert(!done); - expect(handler.isIncrementalResult(chunk)).toBe(true); + assert(handler.isIncrementalResult(chunk)); expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts new file mode 100644 index 00000000000..13c09126dc9 --- /dev/null +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -0,0 +1,1986 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.2 release: +// https://github.com/graphql/graphql-js/blob/042002c3d332d36c67861f5b37d39b74d54d97d4/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function run(document: DocumentNode, rootValue: unknown = {}) { + return executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + document, + rootValue + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("Execute: stream directive", () => { + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client persective, a regular graphql query + }); + + it.skip("Can handle concurrent calls to .next() without waiting", async () => { + // from a client persective, a repeat of a previous test + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the previous test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { + scalarField: null, + }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Can @defer fields that are resolved before async iterable is complete", async () => { + // from a client perspective, a repeat of the previous test + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +test("Defer20220824Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }] }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle({ friendList: [{ id: "1", name: "Luke Cached" }] }, chunk) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new Defer20220824Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts index b61e7d2d4e7..885a428f267 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -1,13 +1,6 @@ import assert from "node:assert"; -import type { - DocumentNode, - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha9"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -15,7 +8,9 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha9"; +import { from } from "rxjs"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -23,13 +18,14 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha9, markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, + promiseWithResolvers, wait, } from "@apollo/client/testing/internal"; @@ -153,65 +149,22 @@ 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( +function run( document: DocumentNode, - rootValue: Record = { hero }, - enableEarlyExecution = false -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - | FormattedExecutionResult, - void -> { - const result = await experimentalExecuteIncrementally({ + rootValue: unknown = { hero }, + enableEarlyExecution?: boolean +) { + return executeSchemaGraphQL17Alpha9( 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; - } + enableEarlyExecution + ); } 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(); - })(); - }); + return from(run(operation.query, rootValue)); }); } diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts new file mode 100644 index 00000000000..7b00b258c9f --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -0,0 +1,2891 @@ +import assert from "node:assert"; + +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +// This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: +// https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +function run( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false +) { + return executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + document, + rootValue, + enableEarlyExecution + ); +} + +function createSchemaLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(operation.query, rootValue)); + }); +} + +describe("graphql-js test cases", () => { + // These test cases mirror stream tests of the `graphql-js` v17.0.0-alpha.9 release: + // https://github.com/graphql/graphql-js/blob/3283f8adf52e77a47f148ff2f30185c8d11ff0f0/src/execution/__tests__/stream-test.ts + + it("Can stream a list field", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can use default value of initialCount", async () => { + const query = gql` + query { + scalarList @stream + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Returns label from stream directive", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Can disable @stream using if argument", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not disable stream with null if argument", async () => { + const query = gql` + query ($shouldStream: Boolean) { + scalarList @stream(initialCount: 2, if: $shouldStream) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => ["apple", "banana", "coconut"], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream multi-dimensional lists", async () => { + const query = gql` + query { + scalarListList @stream(initialCount: 1) + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarListList: () => [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [["apple", "apple", "apple"]], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream in correct order with lists of promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Does not execute early if not specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return f.id; + }, + })), + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns a list with nested promises", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f) => ({ + name: Promise.resolve(f.name), + id: Promise.resolve(f.id), + })), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + name: "Luke", + id: "1", + }, + { + name: "Han", + id: "2", + }, + { + name: "Leia", + id: "3", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error("bad")); + } + return Promise.resolve(f); + }), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable", async () => { + const query = gql` + query { + friendList @stream { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Negative values of initialCount throw field errors on a field that returns an async iterable", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not execute early if not specified, when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + + const slowFriend = async (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Executes early if specified when streaming from an async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + id + } + } + `; + const order: Array = []; + const slowFriend = (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run( + query, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }, + true + ); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can handle concurrent calls to .next() without waiting", async () => { + const query = gql(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles error thrown in async iterable before initialCount is reached", async () => { + // from a client perspective, a regular graphql query + }); + + it("Handles error thrown in async iterable after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error("bad"); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + errors: [ + { + message: "bad", + path: ["friendList"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles null returned in non-null list items after initialCount is reached", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [friends[0], null, friends[1]], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + path: ["nonNullFriendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles null returned in non-null async iterable list items after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + scalarList @stream(initialCount: 1) + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + scalarList: () => [friends[0].name, {}], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke"], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + const query = gql` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + path: ["nonNullFriendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles nested async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + // from a client perspective, a repeat of the last test + }); + + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error("Oops")), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async generator for a non-nullable list", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable does not provide a return method) ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable provides concurrent next/return methods and has a slow return ", async () => { + // from a client perspective, a repeat of a previous test + }); + + it.skip("Filters payloads that are nulled", async () => { + // from a client perspective, a regular graphql query + }); + + it.skip("Filters payloads that are nulled by a later synchronous error", async () => { + // from a client perspective, a regular graphql query + }); + + it("Does not filter payloads when null error is in a different path", async () => { + const query = gql` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => Promise.reject(new Error("Oops")), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters stream payloads that are nulled in a deferred payload", async () => { + const query = gql` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + deeperNestedObject: null, + }, + }, + errors: [ + { + message: + "Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.", + path: ["nestedObject", "deeperNestedObject", "nonNullScalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Filters defer payloads that are nulled in a stream response", async () => { + const query = gql` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns iterator and ignores errors when stream payloads are filtered", async () => { + // from a client perspective, a repeat of a previous test + }); + + it("Handles promises returned by completeValue after initialCount is reached", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + { id: "3", name: "Leia" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Handles overlapping deferred and non-deferred streams", async () => { + const query = gql` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [{ id: "1", name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + // this test does not exist in the original test suite but added to ensure + // deferred non-empty lists are properly merged + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream with > 0 initialCount", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 1) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: {}, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved after async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it("Can @defer fields that are resolved before async iterable is complete", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const query = gql` + query { + friendList @stream(initialCount: 1, label: "stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("Han"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1", name: "Luke" }, { id: "2" }], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveIterableCompletion(null); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("Returns underlying async iterables when returned generator is returned", async () => { + // not interesting from a client perspective + }); + + it.skip("Can return async iterable when underlying iterable does not have a return method", async () => { + // not interesting from a client perspective + }); + + it.skip("Returns underlying async iterables when returned generator is thrown", async () => { + // not interesting from a client perspective + }); +}); + +// quick smoke test. More exhaustive `@stream` tests can be found in +// src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ friendList: friends }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + const query = gql` + query FriendListQuery { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const observableStream = new ObservableStream(client.watchQuery({ query })); + + await expect(observableStream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + loading: true, + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); + +test("properly merges streamed data into cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into partial cache data", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { id: "3" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with fewer items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [{ id: "1", name: "Luke Cached" }], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges streamed data into list with more items", async () => { + const query = gql` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke Cached", id: "1" }, + { name: "Han Cached", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia Cached", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + { name: "Chewbacca Cached", id: "4" }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } +}); + +test("properly merges cache data when list is included in deferred chunk", async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + + const query = gql` + query { + nestedObject { + ...DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `; + + const handler = new GraphQL17Alpha9Handler(); + const request = handler.startRequest({ query }); + + const incoming = run(query, { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + resolveSlowField("slow"); + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "cached", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke Cached" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han Cached" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han Cached" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect( + request.handle( + { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + chunk + ) + ).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } +}); diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 0ed7cd97f59..27ce3d3c96d 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -29,6 +29,7 @@ export declare namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; + incremental?: ReadonlyArray>; }; export type SubsequentResult> = { @@ -36,20 +37,32 @@ export declare namespace Defer20220824Handler { errors?: ReadonlyArray; extensions?: Record; hasNext: boolean; - incremental?: Array>; + incremental?: Array>; }; - export type Chunk> = - | InitialResult - | SubsequentResult; - - export type IncrementalDeferPayload> = { - data?: TData | null | undefined; + export type IncrementalDeferResult> = { + data?: TData | null; errors?: ReadonlyArray; extensions?: Record; path?: Incremental.Path; label?: string; }; + + export type IncrementalStreamResult> = { + errors?: ReadonlyArray; + items?: TData; + path?: Incremental.Path; + label?: string; + extensions?: Record; + }; + + export type IncrementalResult> = + | IncrementalDeferResult + | IncrementalStreamResult; + + export type Chunk> = + | InitialResult + | SubsequentResult; } class DeferRequest> @@ -62,12 +75,9 @@ class DeferRequest> private extensions: Record = {}; private data: any = {}; - private mergeIn( - normalized: FormattedExecutionResult, - merger: DeepMerger - ) { + private merge(normalized: FormattedExecutionResult) { if (normalized.data !== undefined) { - this.data = merger.merge(this.data, normalized.data); + this.data = new DeepMerger().merge(this.data, normalized.data); } if (normalized.errors) { this.errors.push(...normalized.errors); @@ -83,14 +93,21 @@ class DeferRequest> ): FormattedExecutionResult { this.hasNext = chunk.hasNext; this.data = cacheData; - - this.mergeIn(chunk, new DeepMerger()); + this.merge(chunk); if (hasIncrementalChunks(chunk)) { - const merger = new DeepMerger(); for (const incremental of chunk.incremental) { - let { data, path, errors, extensions } = incremental; - if (data && path) { + const { path, errors, extensions } = incremental; + let data = + // The item merged from a `@stream` chunk is always the first item in + // the `items` array + "items" in incremental ? incremental.items?.[0] + // Ensure `data: null` isn't merged for `@defer` responses by + // falling back to `undefined` + : "data" in incremental ? incremental.data ?? undefined + : undefined; + + if (data !== undefined && path) { for (let i = path.length - 1; i >= 0; --i) { const key = path[i]; const isNumericKey = !isNaN(+key); @@ -99,14 +116,11 @@ class DeferRequest> data = parent as typeof data; } } - this.mergeIn( - { - errors, - extensions, - data: data ? (data as TData) : undefined, - }, - merger - ); + this.merge({ + errors, + extensions, + data: data ? (data as TData) : undefined, + }); } } @@ -162,7 +176,7 @@ export class Defer20220824Handler } prepareRequest(request: ApolloLink.Request): ApolloLink.Request { - if (hasDirectives(["defer"], request.query)) { + if (hasDirectives(["defer", "stream"], request.query)) { const context = request.context ?? {}; const http = (context.http ??= {}); http.accept = [ diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 7e4d59af29b..2355ba10b05 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -86,6 +86,14 @@ class IncrementalRequest private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; private pending: GraphQL17Alpha9Handler.PendingResult[] = []; + // `streamPositions` maps `pending.id` to the index that should be set by the + // next `incremental` stream chunk to ensure the streamed array item is placed + // at the correct point in the data array. `this.data` contains cached + // references with the full array so we can't rely on the array length in + // `this.data` to determine where to place item. This also ensures that items + // updated by the cache between a streamed chunk aren't overwritten by merges + // of future stream items from already merged stream items. + private streamPositions: Record = {}; handle( cacheData: TData | DeepPartial | null | undefined = this.data, @@ -96,43 +104,88 @@ class IncrementalRequest if (chunk.pending) { this.pending.push(...chunk.pending); + + if ("data" in chunk) { + for (const pending of chunk.pending) { + const dataAtPath = pending.path.reduce( + (data, key) => (data as any)[key], + chunk.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pending.id] = dataAtPath.length; + } + } + } } - this.merge(chunk, new DeepMerger()); + this.merge(chunk); 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 ?? []); + let data: any; + if ("items" in incremental) { + const items = incremental.items as any[]; + const parent: any[] = []; + + // This creates a sparse array with values set at the indices streamed + // from the server. DeepMerger uses Object.keys and will correctly + // place the values in this array in the correct place + for (let i = 0; i < items.length; i++) { + parent[i + this.streamPositions[pending.id]] = items[i]; + } + + this.streamPositions[pending.id] += items.length; + data = parent; + } else { + data = incremental.data; + + // Check if any pending streams added arrays from deferred data so + // that we can update streamPositions with the initial length of the + // array to ensure future streamed items are inserted at the right + // starting index. + for (const pendingItem of this.pending) { + if (!(pendingItem.id in this.streamPositions)) { + // Check if this incremental data contains array data for the pending path + // The pending path is absolute, but incremental data is relative to the defer + // E.g., pending.path = ["nestedObject"], pendingItem.path = ["nestedObject", "nestedFriendList"] + // incremental.data = { scalarField: "...", nestedFriendList: [...] } + // So we need the path from pending.path onwards + const relativePath = pendingItem.path.slice(pending.path.length); + const dataAtPath = relativePath.reduce( + (data, key) => (data as any)?.[key], + incremental.data + ); + + if (Array.isArray(dataAtPath)) { + this.streamPositions[pendingItem.id] = dataAtPath.length; + } + } + } + } + for (let i = path.length - 1; i >= 0; i--) { const key = path[i]; const parent: Record = typeof key === "number" ? [] : {}; parent[key] = data; - data = parent as typeof data; + data = parent; } - this.merge( - { - data: data as TData, - extensions: incremental.extensions, - errors: incremental.errors, - }, - new DeepMerger() - ); + this.merge({ + data, + extensions: incremental.extensions, + errors: incremental.errors, + }); } } @@ -159,12 +212,9 @@ class IncrementalRequest return result; } - private merge( - normalized: FormattedExecutionResult, - merger: DeepMerger - ) { + private merge(normalized: FormattedExecutionResult) { if (normalized.data !== undefined) { - this.data = merger.merge(this.data, normalized.data); + this.data = new DeepMerger().merge(this.data, normalized.data); } if (normalized.errors) { @@ -193,7 +243,7 @@ export class GraphQL17Alpha9Handler /** @internal */ prepareRequest(request: ApolloLink.Request): ApolloLink.Request { - if (hasDirectives(["defer"], request.query)) { + if (hasDirectives(["defer", "stream"], request.query)) { const context = request.context ?? {}; const http = (context.http ??= {}); http.accept = ["multipart/mixed", ...(http.accept || [])]; diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index a60975fd827..69792fab1c3 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -19,7 +19,10 @@ import { PROTOCOL_ERRORS_SYMBOL, ServerParseError, } from "@apollo/client/errors"; -import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { ApolloLink } from "@apollo/client/link"; import { BaseHttpLink, HttpLink } from "@apollo/client/link/http"; import { @@ -57,6 +60,15 @@ const sampleDeferredQuery = gql` } `; +const sampleStreamedQuery = gql` + query SampleDeferredQuery { + stubs @stream { + id + name + } + } +`; + const sampleQueryCustomDirective = gql` query SampleDeferredQuery { stub { @@ -1341,6 +1353,57 @@ describe("HttpLink", () => { "-----", ].join("\r\n"); + const bodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"pending":[{"id":"0","path":["stub"]}],"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"name":"stubby---"},"id":"0","extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBody = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"data":{"id":"1","name":"stubby---"},"path":["stubs", 1],"extensions":{"timestamp":1633038919}}]}', + "-----", + ].join("\r\n"); + + const streamBodyAlpha9 = [ + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stubs":[]},"pending": [{"id":"0","path":["stubs"]}], "hasNext":true}', + "---", + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + // Intentionally using the boundary value `---` within the “name” to + // validate that boundary delimiters are not parsed within the response + // data itself, only read at the beginning of each chunk. + '{"hasNext":false, "incremental": [{"items":[{"id":"1","name":"stubby---"}],"id":"0","extensions":{"timestamp":1633038919}}],"completed":[{"id":"0"}]}', + "-----", + ].join("\r\n"); + const finalChunkOnlyHasNextFalse = [ "--graphql", "content-type: application/json", @@ -1524,6 +1587,169 @@ describe("HttpLink", () => { ); }); + it("sets correct accept header on request with deferred query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + bodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleDeferredQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stub: { id: "0" } }, + // @ts-ignore + pending: [{ id: "0", path: ["stub"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { name: "stubby---" }, + // @ts-ignore + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query", async () => { + const stream = ReadableStream.from( + streamBody.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + data: { id: "1", name: "stubby---" }, + path: ["stubs", 1], + extensions: { timestamp: 1633038919 }, + }, + ], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + + it("sets correct accept header on request with streamed query using GraphQL17Alpha9Handler", async () => { + const stream = ReadableStream.from( + streamBodyAlpha9.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => { + return new Response(stream, { + status: 200, + headers: { "content-type": "multipart/mixed" }, + }); + }); + + const { link, observableStream } = pipeLinkToObservableStream( + new HttpLink({ fetch }) + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + void client.query({ query: sampleStreamedQuery }); + + await expect(observableStream).toEmitTypedValue({ + data: { stubs: [] }, + // @ts-ignore + pending: [{ id: "0", path: ["stubs"] }], + hasNext: true, + }); + + await expect(observableStream).toEmitTypedValue({ + incremental: [ + { + // @ts-ignore + items: [{ id: "1", name: "stubby---" }], + id: "0", + extensions: { timestamp: 1633038919 }, + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(observableStream).toComplete(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed,application/graphql-response+json,application/json;q=0.9", + }, + }) + ); + }); + // ensure that custom directives beginning with '@defer..' do not trigger // custom accept header for multipart responses it("sets does not set accept header on query with custom directive begging with @defer", async () => { diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..b34b85aa6c6 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx @@ -0,0 +1,414 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + // using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..fd15889a9ad --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,414 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { from } from "rxjs"; + +import type { + DataState, + ErrorLike, + OperationVariables, + TypedDocumentNode, +} from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import type { QueryRef } from "@apollo/client/react"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +test('does not suspend deferred queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + interface Data { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + cache.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useBackgroundQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('does not suspend deferred queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { stream, subject } = asyncIterableSubject(); + interface QueryData { + friendList: Array<{ __typename: "Friend"; id: string; name: string }>; + } + + const query: TypedDocumentNode = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We are intentionally writing partial data to the cache. Supress console + // warnings to avoid unnecessary noise in the test. + { + // using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + // @ts-expect-error + { __typename: "Friend", id: "1" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useBackgroundQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + error: undefined, + networkStatus: NetworkStatus.loading, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + // @ts-expect-error + { __typename: "Friend", id: "2" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + // @ts-expect-error + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "streaming", + error: undefined, + networkStatus: NetworkStatus.streaming, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..6432ce1d093 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx @@ -0,0 +1,783 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + // using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..ab0e022c798 --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,783 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { from } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("should handle streamed queries", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with fetch policy no-cache", async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("should handle streamed queries with errors returned on the incremental batched result", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + // Emit these to show that errorPolicy of none cuts off future updates + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('should handle streamed queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + subject.next(new Error("Could not load friend")); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [ + { + message: "Could not load friend", + path: ["friendList", 1], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test('returns eventually consistent data from streamed queries with data in the cache while using a "cache-and-network" fetch policy', async () => { + const { subject, stream } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('returns eventually consistent data from streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + // We know we are writing partial data to the cache so suppress the console + // warning. + { + // using _consoleSpy = spyOnConsole("error"); + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + dataState: "partial", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + subject.next(friends[0]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[1]); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + subject.next(friends[2]); + subject.complete(); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx new file mode 100644 index 00000000000..0d2d441d4c5 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -0,0 +1,1707 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + 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: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new Defer20220824Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// TODO: This test is a bit of a lie. `fetchMore` should incrementally +// rerender when using `@stream` 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.failing( + "rerenders data returned by `fetchMore` for a streamed query", + async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + // TODO: Re-enable once the core bug is fixed + // { + // const { snapshot, renderedComponents } = await takeRender(); + // + // expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + // expect(snapshot).toStrictEqualTyped({ + // data: markAsStreaming({ + // friendList: [ + // { __typename: "Friend", id: "1", name: "Luke" }, + // { __typename: "Friend", id: "2", name: "Han" }, + // { __typename: "Friend", id: "3", name: "Leia" }, + // ], + // }), + // dataState: "streaming", + // networkStatus: NetworkStatus.streaming, + // error: undefined, + // }); + // } + + await wait(0); + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); + } +); + +// 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 +test.failing( + "incrementally rerenders data returned by a `fetchMore` for a streamed query", + async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + await wait(0); + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); + } +); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new Defer20220824Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(new Error("Could not get friend")); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx new file mode 100644 index 00000000000..8eafae1220b --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,1735 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { Subject } from "rxjs"; +import { delay, from, throwError } from "rxjs"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + asyncIterableSubject, + createClientWrapper, + executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, + markAsStreaming, + spyOnConsole, + wait, +} from "@apollo/client/testing/internal"; +import { offsetLimitPagination } from "@apollo/client/utilities"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +// TODO: Determine how we handle partial data with streamed responses. While this +// works as expected and renders correctly, this also emits missing field +// warnings in the console when writing the result to the cache since array items +// with partial cache data are still included for items that haven't streamed in +// yet. +test('does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { + using _TODO_REMOVE_ME_AFTER_DECIDING_COMMENT = spyOnConsole("error"); + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + 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: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + }); + } + + const client = new ApolloClient({ + cache, + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: "cache-first", + returnPartialData: true, + }), + { + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + id: String(friend.id), + })), + }, + dataState: "partial", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test('does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + client.writeQuery({ + query, + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Cached Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Cached Han" }, + { __typename: "Friend", id: "3", name: "Cached Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("incrementally rerenders data returned by a `refetch` for a streamed query", async () => { + let subject!: Subject; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next({ id: 1, name: "Luke (refetch)" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (refetch)" }, + { __typename: "Friend", id: "2", name: "Han (refetch)" }, + { __typename: "Friend", id: "3", name: "Leia (refetch)" }, + ], + }, + }); +}); + +test("incrementally renders data returned after skipping a streamed query", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + link: createLink({ friendList: () => stream }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using __disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderSuspenseHook( + ({ skip }) => useSuspenseQuery(query, { skip }), + { + initialProps: { skip: true }, + wrapper: createClientWrapper(client), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await rerender({ skip: false }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +// TODO: This test is a bit of a lie. `fetchMore` should incrementally +// rerender when using `@stream` 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.failing( + "rerenders data returned by `fetchMore` for a streamed query", + async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + // TODO: Re-enable once the core bug is fixed + // { + // const { snapshot, renderedComponents } = await takeRender(); + // + // expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + // expect(snapshot).toStrictEqualTyped({ + // data: markAsStreaming({ + // friendList: [ + // { __typename: "Friend", id: "1", name: "Luke" }, + // { __typename: "Friend", id: "2", name: "Han" }, + // { __typename: "Friend", id: "3", name: "Leia" }, + // ], + // }), + // dataState: "streaming", + // networkStatus: NetworkStatus.streaming, + // error: undefined, + // }); + // } + + await wait(0); + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); + } +); + +// 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 +test.failing( + "incrementally rerenders data returned by a `fetchMore` for a streamed query", + async () => { + let subject!: Subject; + const query = gql` + query ($offset: Int) { + friendList(offset: $offset) @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + greetings: offsetLimitPagination(), + }, + }, + }, + }); + + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterator = asyncIterableSubject(); + subject = iterator.subject; + + return iterator.stream; + }, + }), + cache, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { offset: 0 } }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const fetchMorePromise = getCurrentSnapshot().fetchMore({ + variables: { offset: 2 }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + await wait(0); + subject.next({ id: 4, name: "Chewbacca" }); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "3", name: "Leia" }, + { __typename: "Friend", id: "4", name: "Chewbacca" }, + ], + }, + }); + + await expect(takeRender).not.toRerender(); + } +); + +test("throws network errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => { + return throwError(() => new Error("Could not fetch")).pipe(delay(20)); + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new Error("Could not fetch"), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("throws graphql errors returned by streamed queries", async () => { + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + await wait(20); + throw new Error("Could not get friend list"); + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { friendList: null }, + errors: [ + { message: "Could not get friend list", path: ["friendList"] }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("discards partial data and throws errors returned in incremental chunks", async () => { + const { stream, subject } = asyncIterableSubject(); + using _consoleSpy = spyOnConsole("error"); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async function* () { + for await (const friend of stream) { + if (friend.id === 2) { + throw new Error("Could not get friend"); + } + + yield friend; + } + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["ErrorBoundary"]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }, + errors: [{ message: "Could not get friend", path: ["friendList"] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const { stream, subject } = asyncIterableSubject(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const { stream, subject } = asyncIterableSubject>(); + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => stream, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", async () => { + let subject!: Subject | Friend>; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + const iterable = asyncIterableSubject | Friend>(); + subject = iterable.subject; + + return iterable.stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(Promise.reject(new Error("Could not get friend"))); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: new CombinedGraphQLErrors({ + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } + + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + null, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + }); + + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke (updated)" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/internal/cache/QueryReference.ts b/src/react/internal/cache/QueryReference.ts index cef2a7f6ec2..e02c76303f9 100644 --- a/src/react/internal/cache/QueryReference.ts +++ b/src/react/internal/cache/QueryReference.ts @@ -186,6 +186,7 @@ export class InternalQueryReference< public promise!: QueryRefPromise; + private queue: QueryRefPromise | undefined; private subscription!: Subscription; private listeners = new Set>(); private autoDisposeTimeoutId?: NodeJS.Timeout; @@ -335,6 +336,11 @@ export class InternalQueryReference< listen(listener: Listener) { this.listeners.add(listener); + if (this.queue) { + this.deliver(this.queue); + this.queue = undefined; + } + return () => { this.listeners.delete(listener); }; @@ -412,6 +418,18 @@ export class InternalQueryReference< } private deliver(promise: QueryRefPromise) { + // Maintain a queue of the last item we tried to deliver so that we can + // deliver it as soon as we get the first listener. This helps in cases such + // as `@stream` where React may render a component and incremental results + // are loaded in between when the component renders and effects are run. If + // effects are run after the incremntal chunks are delivered, we'll have + // rendered a stale value. The queue ensures we can deliver the most + // up-to-date value as soon as the component is ready to listen for new + // values. + if (this.listeners.size === 0) { + this.queue = promise; + } + this.listeners.forEach((listener) => listener(promise)); } diff --git a/src/testing/internal/asyncIterableSubject.ts b/src/testing/internal/asyncIterableSubject.ts new file mode 100644 index 00000000000..9085dd149b4 --- /dev/null +++ b/src/testing/internal/asyncIterableSubject.ts @@ -0,0 +1,16 @@ +import { Subject } from "rxjs"; + +export function asyncIterableSubject() { + const subject = new Subject(); + + const stream = new ReadableStream({ + start: (controller) => { + subject.subscribe({ + next: (value) => controller.enqueue(value), + complete: () => controller.close(), + }); + }, + }); + + return { subject, stream }; +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts new file mode 100644 index 00000000000..eeba9cde67c --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts @@ -0,0 +1,36 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha2"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha2"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha2( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {} +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts new file mode 100644 index 00000000000..8285297367b --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts @@ -0,0 +1,38 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, + GraphQLSchema, +} from "graphql-17-alpha9"; +import { experimentalExecuteIncrementally } from "graphql-17-alpha9"; + +import type { DocumentNode } from "@apollo/client"; + +export async function* executeSchemaGraphQL17Alpha9( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution?: boolean +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index 37fad789108..de686fc955d 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,6 +23,10 @@ export { } from "./scenarios/index.js"; export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.js"; +export { asyncIterableSubject } from "./asyncIterableSubject.js"; +export { executeSchemaGraphQL17Alpha2 } from "./incremental/executeSchemaGraphQL17Alpha2.js"; +export { executeSchemaGraphQL17Alpha9 } from "./incremental/executeSchemaGraphQL17Alpha9.js"; +export { promiseWithResolvers } from "./promiseWithResolvers.js"; export { renderAsync } from "./rtl/renderAsync.js"; export { renderHookAsync } from "./rtl/renderHookAsync.js"; export { mockDefer20220824 } from "./multipart/mockDefer20220824.js"; @@ -35,3 +39,6 @@ export { } from "./link.js"; export { markAsStreaming } from "./markAsStreaming.js"; export { wait } from "./wait.js"; + +export { friendListSchemaGraphQL17Alpha2 } from "./schemas/friendList.graphql17Alpha2.js"; +export { friendListSchemaGraphQL17Alpha9 } from "./schemas/friendList.graphql17Alpha9.js"; diff --git a/src/testing/internal/promiseWithResolvers.ts b/src/testing/internal/promiseWithResolvers.ts new file mode 100644 index 00000000000..68283719b04 --- /dev/null +++ b/src/testing/internal/promiseWithResolvers.ts @@ -0,0 +1,15 @@ +export function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | Promise) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | Promise) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha2.ts b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts new file mode 100644 index 00000000000..17d59da59a4 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha2"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha2 = new GraphQLSchema({ query }); diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha9.ts b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts new file mode 100644 index 00000000000..4f774afab13 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts @@ -0,0 +1,68 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: "NestedObject", + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: "DeeperNestedObject", + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: "Query", +}); + +export const friendListSchemaGraphQL17Alpha9 = new GraphQLSchema({ query }); diff --git a/src/utilities/internal/getStoreKeyName.ts b/src/utilities/internal/getStoreKeyName.ts index e8056c246af..0b63ebcce30 100644 --- a/src/utilities/internal/getStoreKeyName.ts +++ b/src/utilities/internal/getStoreKeyName.ts @@ -14,6 +14,7 @@ const KNOWN_DIRECTIVES: string[] = [ "rest", "export", "nonreactive", + "stream", ]; // Default stable JSON.stringify implementation used by getStoreKeyName. Can be diff --git a/tsconfig.json b/tsconfig.json index 7bbdcf7fdcc..578691cbab1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "experimentalDecorators": true, "outDir": "./dist", "rootDir": "./src", - "lib": ["DOM", "ES2023"], + "lib": ["DOM", "dom.asyncIterable", "ES2023"], "types": [ "jest", "node",