From 49873cb36816949385bbfa5d3cd54b22d28a6fcc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 11:28:42 -0600 Subject: [PATCH 01/97] Add stream tests --- .../__tests__/graphql17Alpha9/stream.test.ts | 2505 +++++++++++++++++ 1 file changed, 2505 insertions(+) create mode 100644 src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts 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..2dda1d23da3 --- /dev/null +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -0,0 +1,2505 @@ +import assert from "node:assert"; + +import type { + DocumentNode, + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha9"; +import { + experimentalExecuteIncrementally, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; + +import { gql } from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; + +import { + hasIncrementalChunks, + // eslint-disable-next-line local-rules/no-relative-imports +} from "../../graphql17Alpha9.js"; + +// 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 friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + 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", +}); + +const schema = new GraphQLSchema({ query }); + +function resolveOnNextTick(): Promise { + return Promise.resolve(undefined); +} + +type PromiseOrValue = Promise | T; + +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseOrValue) => void; + reject: (reason?: any) => void; +} { + // these are assigned synchronously within the Promise constructor + let resolve!: (value: T | PromiseOrValue) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function* run( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} + +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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["apple", "banana", "coconut"], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarListList: [ + ["apple", "apple", "apple"], + ["banana", "banana", "banana"], + ["coconut", "coconut", "coconut"], + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + items: [{ name: "Luke", id: "1" }], + id: "0", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + items: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + ], + id: "0", + }, + ], + }, + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { + items: [ + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, + ], + id: "0", + }, + ], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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 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(hasIncrementalChunks(chunk)).toBe(false); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }, null], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [ + { name: "Luke", id: "1" }, + null, + { name: "Leia", id: "3" }, + ], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + path: ["friendList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(false); + 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("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(false); + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ name: "Luke", id: "1" }], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + path: ["friendList"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ name: "Luke", id: "1" }], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Query.nonNullFriendList.", + locations: [{ line: 3, column: 9 }], + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + scalarList: ["Luke", null], + }, + errors: [ + { + message: "String cannot represent value: {}", + locations: [{ line: 3, column: 9 }], + path: ["scalarList", 1], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nonNullFriendList: [{ nonNullName: "Luke" }], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { name: "Han" }], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + path: ["friendList", 1, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [{ nonNullName: "Luke" }, null, { name: "Han" }], + }, + errors: [ + { + message: "Oops", + locations: [{ line: 4, column: 11 }], + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(false); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + locations: [{ line: 5, column: 13 }], + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(false); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + otherNestedObject: { scalarField: null }, + nestedObject: { nestedFriendList: [{ name: "Luke" }] }, + }, + errors: [ + { + message: "Oops", + locations: [{ line: 5, column: 13 }], + path: ["otherNestedObject", "scalarField"], + }, + ], + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + locations: [{ line: 4, column: 9 }], + path: ["friendList", 0, "nonNullName"], + }, + ], + }); + expect(request.hasNext).toBe(true); + } + + { + const { value: chunk, done } = await incoming.next(); + + assert(!done); + assert(handler.isIncrementalResult(chunk)); + expect(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + friendList: [null], + }, + errors: [ + { + message: + "Cannot return null for non-nullable field Friend.nonNullName.", + locations: [{ line: 4, column: 9 }], + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + nestedFriendList: [ + { id: "1", name: "Luke" }, + { id: "2", name: "Han" }, + ], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + expect(request.handle(undefined, chunk)).toStrictEqualTyped({ + data: { + nestedObject: { + scalarField: "slow", + nestedFriendList: [{ name: "Luke" }, { name: "Han" }], + }, + }, + }); + expect(request.hasNext).toBe(false); + } + }); + + it.skip("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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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 () => { + 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(hasIncrementalChunks(chunk)).toBe(false); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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(hasIncrementalChunks(chunk)).toBe(true); + 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 + }); +}); From b674efafd9ada97c59b7458e33ec02391a0c6424 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 11:38:50 -0600 Subject: [PATCH 02/97] First implementation of stream --- .../__tests__/graphql17Alpha9/stream.test.ts | 2 +- src/incremental/handlers/graphql17Alpha9.ts | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 2dda1d23da3..01205133714 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -140,7 +140,7 @@ 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.skip("Can stream a list field", async () => { + it("Can stream a list field", async () => { const query = gql` query { scalarList @stream(initialCount: 1) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 7e4d59af29b..a38513b590b 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -102,21 +102,34 @@ class IncrementalRequest 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 ?? []); + if ("items" in incremental) { + const array = path.reduce((data, key) => { + // Use `&&` to maintain `null` if encountered + return data && data[key]; + }, this.data); + + invariant( + Array.isArray(array), + `@stream: value at path %o is not an array. Please file an issue for the Apollo Client team to investigate.`, + path + ); + + array.push(...(incremental.items as ReadonlyArray)); + + continue; + } + + let { data } = incremental; + for (let i = path.length - 1; i >= 0; i--) { const key = path[i]; const parent: Record = From dbd6eda76ee6ccdc1a3e17cd681cc987c16a3695 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 11:41:15 -0600 Subject: [PATCH 03/97] Enable all tests --- .../__tests__/graphql17Alpha9/stream.test.ts | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 01205133714..8c6557e7664 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -183,7 +183,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can use default value of initialCount", async () => { + it("Can use default value of initialCount", async () => { const query = gql` query { scalarList @stream @@ -237,7 +237,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a regular graphql query }); - it.skip("Does not disable stream with null if argument", async () => { + it("Does not disable stream with null if argument", async () => { const query = gql` query ($shouldStream: Boolean) { scalarList @stream(initialCount: 2, if: $shouldStream) @@ -280,7 +280,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream multi-dimensional lists", async () => { + it("Can stream multi-dimensional lists", async () => { const query = gql` query { scalarListList @stream(initialCount: 1) @@ -330,7 +330,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream a field that returns a list of promises", async () => { + it("Can stream a field that returns a list of promises", async () => { const query = gql` query { friendList @stream(initialCount: 2) { @@ -398,7 +398,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream in correct order with lists of promises", async () => { + it("Can stream in correct order with lists of promises", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -494,7 +494,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Does not execute early if not specified", async () => { + it("Does not execute early if not specified", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -575,7 +575,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Executes early if specified", async () => { + it("Executes early if specified", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -632,7 +632,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream a field that returns a list with nested promises", async () => { + it("Can stream a field that returns a list with nested promises", async () => { const query = gql` query { friendList @stream(initialCount: 2) { @@ -703,7 +703,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Handles rejections in a field that returns a list of promises before initialCount is reached", async () => { + 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) { @@ -773,7 +773,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Handles rejections in a field that returns a list of promises after initialCount is reached", async () => { + 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) { @@ -857,7 +857,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream a field that returns an async iterable", async () => { + it("Can stream a field that returns an async iterable", async () => { const query = gql` query { friendList @stream { @@ -960,7 +960,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { + it("Can stream a field that returns an async iterable, using a non-zero initialCount", async () => { const query = gql` query { friendList @stream(initialCount: 2) { @@ -1039,7 +1039,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a regular graphql query }); - it.skip("Does not execute early if not specified, when streaming from an async iterable", async () => { + it("Does not execute early if not specified, when streaming from an async iterable", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -1140,7 +1140,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Executes early if specified when streaming from an async iterable", async () => { + it("Executes early if specified when streaming from an async iterable", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -1204,7 +1204,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can handle concurrent calls to .next() without waiting", async () => { + it("Can handle concurrent calls to .next() without waiting", async () => { const query = gql(` query { friendList @stream(initialCount: 2) { @@ -1283,7 +1283,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a regular graphql query }); - it.skip("Handles error thrown in async iterable after initialCount is reached", async () => { + it("Handles error thrown in async iterable after initialCount is reached", async () => { const query = gql` query { friendList @stream(initialCount: 1) { @@ -1339,7 +1339,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Handles null returned in non-null list items after initialCount is reached", async () => { + it("Handles null returned in non-null list items after initialCount is reached", async () => { const query = gql` query { nonNullFriendList @stream(initialCount: 1) { @@ -1396,7 +1396,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a repeat of the last test }); - it.skip("Handles errors thrown by completeValue after initialCount is reached", async () => { + it("Handles errors thrown by completeValue after initialCount is reached", async () => { const query = gql` query { scalarList @stream(initialCount: 1) @@ -1446,7 +1446,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Handles async errors thrown by completeValue after initialCount is reached", async () => { + it("Handles async errors thrown by completeValue after initialCount is reached", async () => { const query = gql` query { friendList @stream(initialCount: 1) { @@ -1529,7 +1529,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a repeat of the last test }); - it.skip("Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list", async () => { + 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) { @@ -1591,7 +1591,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a repeat of the last test }); - it.skip("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { + it("Handles async errors thrown by completeValue after initialCount is reached from async iterable", async () => { const query = gql` query { friendList @stream(initialCount: 1) { @@ -1711,7 +1711,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a regular graphql query }); - it.skip("Does not filter payloads when null error is in a different path", async () => { + it("Does not filter payloads when null error is in a different path", async () => { const query = gql` query { otherNestedObject: nestedObject { @@ -1799,7 +1799,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Filters stream payloads that are nulled in a deferred payload", async () => { + it("Filters stream payloads that are nulled in a deferred payload", async () => { const query = gql` query { nestedObject { @@ -1867,7 +1867,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Filters defer payloads that are nulled in a stream response", async () => { + it("Filters defer payloads that are nulled in a stream response", async () => { const query = gql` query { friendList @stream(initialCount: 0) { @@ -1954,7 +1954,7 @@ describe("graphql-js test cases", () => { // from a client perspective, a repeat of a previous test }); - it.skip("Handles promises returned by completeValue after initialCount is reached", async () => { + it("Handles promises returned by completeValue after initialCount is reached", async () => { const query = gql` query { friendList @stream(initialCount: 1) { @@ -2046,7 +2046,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Handles overlapping deferred and non-deferred streams", async () => { + it("Handles overlapping deferred and non-deferred streams", async () => { const query = gql` query { nestedObject { @@ -2148,7 +2148,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { + it("Returns payloads in correct order when parent deferred fragment resolves slower than stream", async () => { const { promise: slowFieldPromise, resolve: resolveSlowField } = promiseWithResolvers(); @@ -2264,7 +2264,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can @defer fields that are resolved after async iterable is complete", async () => { + it("Can @defer fields that are resolved after async iterable is complete", async () => { const { promise: slowFieldPromise, resolve: resolveSlowField } = promiseWithResolvers(); const { @@ -2376,7 +2376,7 @@ describe("graphql-js test cases", () => { } }); - it.skip("Can @defer fields that are resolved before async iterable is complete", async () => { + it("Can @defer fields that are resolved before async iterable is complete", async () => { const { promise: slowFieldPromise, resolve: resolveSlowField } = promiseWithResolvers(); const { From b3f35f4fac5d91e7fb8afe0eceb6bbac472fdce5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 11:48:17 -0600 Subject: [PATCH 04/97] Fix some incorrect assertions on hasIncrementalChunks --- .../__tests__/graphql17Alpha9/stream.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 8c6557e7664..f9a500e269b 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -1021,7 +1021,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1130,7 +1130,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -1322,7 +1322,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -1374,7 +1374,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ name: "Luke", id: "1" }], @@ -1570,7 +1570,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ nonNullName: "Luke" }], @@ -1759,7 +1759,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); + expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { otherNestedObject: { scalarField: null }, @@ -2032,7 +2032,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -2133,7 +2133,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2251,7 +2251,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2349,7 +2349,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }, { id: "2" }], @@ -2478,7 +2478,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ From 886be171a47644a432b69ad67036663e6e6d57b5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 11:50:06 -0600 Subject: [PATCH 05/97] Remove locations from errors in assertions --- .../__tests__/graphql17Alpha9/stream.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index f9a500e269b..1ae11c508d1 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -739,7 +739,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -764,7 +763,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -823,7 +821,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -848,7 +845,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -1330,7 +1326,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList"], }, ], @@ -1383,7 +1378,6 @@ describe("graphql-js test cases", () => { { message: "Cannot return null for non-nullable field Query.nonNullFriendList.", - locations: [{ line: 3, column: 9 }], path: ["nonNullFriendList", 1], }, ], @@ -1437,7 +1431,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "String cannot represent value: {}", - locations: [{ line: 3, column: 9 }], path: ["scalarList", 1], }, ], @@ -1495,7 +1488,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["friendList", 1, "nonNullName"], }, ], @@ -1516,7 +1508,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["friendList", 1, "nonNullName"], }, ], @@ -1578,7 +1569,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["nonNullFriendList", 1, "nonNullName"], }, ], @@ -1640,7 +1630,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["friendList", 1, "nonNullName"], }, ], @@ -1661,7 +1650,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["friendList", 1, "nonNullName"], }, ], @@ -1682,7 +1670,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 4, column: 11 }], path: ["friendList", 1, "nonNullName"], }, ], @@ -1768,7 +1755,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 5, column: 13 }], path: ["otherNestedObject", "scalarField"], }, ], @@ -1790,7 +1776,6 @@ describe("graphql-js test cases", () => { errors: [ { message: "Oops", - locations: [{ line: 5, column: 13 }], path: ["otherNestedObject", "scalarField"], }, ], @@ -1919,7 +1904,6 @@ describe("graphql-js test cases", () => { { message: "Cannot return null for non-nullable field Friend.nonNullName.", - locations: [{ line: 4, column: 9 }], path: ["friendList", 0, "nonNullName"], }, ], @@ -1941,7 +1925,6 @@ describe("graphql-js test cases", () => { { message: "Cannot return null for non-nullable field Friend.nonNullName.", - locations: [{ line: 4, column: 9 }], path: ["friendList", 0, "nonNullName"], }, ], From 503951eee11d790e2a49de21a7ae922cfa10cb56 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 13:12:31 -0600 Subject: [PATCH 06/97] Formatting --- src/incremental/handlers/graphql17Alpha9.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index a38513b590b..6914e823336 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -112,10 +112,11 @@ class IncrementalRequest const path = pending.path.concat(incremental.subPath ?? []); if ("items" in incremental) { - const array = path.reduce((data, key) => { + const array = path.reduce( // Use `&&` to maintain `null` if encountered - return data && data[key]; - }, this.data); + (data, key) => data && data[key], + this.data + ); invariant( Array.isArray(array), From 5f29b631bad76791fd0cb926c8f6e21cd09405ab Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 13:12:37 -0600 Subject: [PATCH 07/97] Merge errors for streamed results --- src/incremental/handlers/graphql17Alpha9.ts | 46 ++++++++++++--------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 6914e823336..91b6d0a6fd7 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -126,27 +126,33 @@ class IncrementalRequest array.push(...(incremental.items as ReadonlyArray)); - continue; - } - - let { data } = incremental; - - for (let i = path.length - 1; i >= 0; i--) { - const key = path[i]; - const parent: Record = - typeof key === "number" ? [] : {}; - parent[key] = data; - data = parent as typeof data; + this.merge( + { + extensions: incremental.extensions, + errors: incremental.errors, + }, + new DeepMerger() + ); + } else { + let { data } = incremental; + + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = data; + data = parent as typeof data; + } + + this.merge( + { + data: data as TData, + extensions: incremental.extensions, + errors: incremental.errors, + }, + new DeepMerger() + ); } - - this.merge( - { - data: data as TData, - extensions: incremental.extensions, - errors: incremental.errors, - }, - new DeepMerger() - ); } } From ad1276f9c6622ca74ff7bec8e07450fdf9191df4 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 13:15:03 -0600 Subject: [PATCH 08/97] Fix incorrect assertions --- .../__tests__/graphql17Alpha9/stream.test.ts | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 1ae11c508d1..5c546d1d71a 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -437,12 +437,7 @@ describe("graphql-js test cases", () => { expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { - friendList: [ - { - items: [{ name: "Luke", id: "1" }], - id: "0", - }, - ], + friendList: [{ name: "Luke", id: "1" }], }, }); expect(request.hasNext).toBe(true); @@ -457,13 +452,8 @@ describe("graphql-js test cases", () => { expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ - { - items: [ - { name: "Luke", id: "1" }, - { name: "Han", id: "2" }, - ], - id: "0", - }, + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, ], }, }); @@ -479,14 +469,9 @@ describe("graphql-js test cases", () => { expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ - { - items: [ - { name: "Luke", id: "1" }, - { name: "Han", id: "2" }, - { name: "Leia", id: "3" }, - ], - id: "0", - }, + { name: "Luke", id: "1" }, + { name: "Han", id: "2" }, + { name: "Leia", id: "3" }, ], }, }); @@ -1372,7 +1357,7 @@ describe("graphql-js test cases", () => { expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { - nonNullFriendList: [{ name: "Luke", id: "1" }], + nonNullFriendList: [{ name: "Luke" }], }, errors: [ { @@ -1645,7 +1630,7 @@ describe("graphql-js test cases", () => { expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { - friendList: [{ nonNullName: "Luke" }, null, { name: "Han" }], + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], }, errors: [ { @@ -1662,10 +1647,10 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { - friendList: [{ nonNullName: "Luke" }, null, { name: "Han" }], + friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], }, errors: [ { @@ -1916,7 +1901,7 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); + expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [null], From 226a6efdca55d2f3b6198de0561346d2ad6b301c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 13:18:03 -0600 Subject: [PATCH 09/97] Remove assertions on hasIncrementalChunks --- .../__tests__/graphql17Alpha9/stream.test.ts | 96 ------------------- 1 file changed, 96 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 5c546d1d71a..56a519d258f 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -19,11 +19,6 @@ import { import { gql } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; -import { - hasIncrementalChunks, - // eslint-disable-next-line local-rules/no-relative-imports -} from "../../graphql17Alpha9.js"; - // 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 @@ -159,7 +154,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["apple"], @@ -173,7 +167,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["apple", "banana", "coconut"], @@ -201,7 +194,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: [], @@ -215,7 +207,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["apple", "banana", "coconut"], @@ -256,7 +247,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["apple", "banana"], @@ -270,7 +260,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["apple", "banana", "coconut"], @@ -302,7 +291,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarListList: [["apple", "apple", "apple"]], @@ -316,7 +304,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarListList: [ @@ -352,7 +339,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -375,7 +361,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -420,7 +405,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -434,7 +418,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -448,7 +431,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -465,7 +447,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -508,7 +489,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -522,7 +502,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }], @@ -536,7 +515,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }], @@ -550,7 +528,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -593,7 +570,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -607,7 +583,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -642,7 +617,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -665,7 +639,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -716,7 +689,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }, null], @@ -736,7 +708,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -784,7 +755,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -798,7 +768,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }, null], @@ -818,7 +787,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -864,7 +832,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -878,7 +845,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -892,7 +858,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -909,7 +874,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -927,7 +891,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -967,7 +930,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -984,7 +946,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1002,7 +963,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1055,7 +1015,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -1069,7 +1028,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }], @@ -1083,7 +1041,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }], @@ -1097,7 +1054,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -1111,7 +1067,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -1161,7 +1116,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -1175,7 +1129,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], @@ -1211,7 +1164,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1228,7 +1180,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1246,7 +1197,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1289,7 +1239,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -1303,7 +1252,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ name: "Luke", id: "1" }], @@ -1340,7 +1288,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ name: "Luke" }], @@ -1354,7 +1301,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ name: "Luke" }], @@ -1394,7 +1340,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["Luke"], @@ -1408,7 +1353,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { scalarList: ["Luke", null], @@ -1451,7 +1395,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }], @@ -1465,7 +1408,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }, null], @@ -1485,7 +1427,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], @@ -1532,7 +1473,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ nonNullName: "Luke" }], @@ -1546,7 +1486,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nonNullFriendList: [{ nonNullName: "Luke" }], @@ -1593,7 +1532,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }], @@ -1607,7 +1545,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }, null], @@ -1627,7 +1564,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], @@ -1647,7 +1583,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ nonNullName: "Luke" }, null, { nonNullName: "Han" }], @@ -1716,7 +1651,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { otherNestedObject: {}, @@ -1731,7 +1665,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { otherNestedObject: { scalarField: null }, @@ -1752,7 +1685,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { otherNestedObject: { scalarField: null }, @@ -1804,7 +1736,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: {}, @@ -1818,7 +1749,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -1866,7 +1796,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -1880,7 +1809,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [null], @@ -1901,7 +1829,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [null], @@ -1951,7 +1878,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }], @@ -1965,7 +1891,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -1982,7 +1907,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -2000,7 +1924,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -2050,7 +1973,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2066,7 +1988,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2082,7 +2003,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2101,7 +2021,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2152,7 +2071,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: {}, @@ -2168,7 +2086,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2185,7 +2102,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2202,7 +2118,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2219,7 +2134,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { nestedObject: { @@ -2271,7 +2185,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [], @@ -2287,7 +2200,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }], @@ -2303,7 +2215,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }, { id: "2" }], @@ -2317,7 +2228,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }, { id: "2" }], @@ -2331,7 +2241,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -2383,7 +2292,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1" }], @@ -2399,7 +2307,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }], @@ -2413,7 +2320,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [{ id: "1", name: "Luke" }, { id: "2" }], @@ -2427,7 +2333,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(true); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ @@ -2446,7 +2351,6 @@ describe("graphql-js test cases", () => { assert(!done); assert(handler.isIncrementalResult(chunk)); - expect(hasIncrementalChunks(chunk)).toBe(false); expect(request.handle(undefined, chunk)).toStrictEqualTyped({ data: { friendList: [ From 6b69a4e68cea5e0449ec9b7276e03f3cb86fb58b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 14:10:10 -0600 Subject: [PATCH 10/97] Add necessary changes to writeToStore to handle stream --- src/cache/inmemory/writeToStore.ts | 52 ++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index b44b6eb02f6..761d6e54ca6 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -79,17 +79,18 @@ export interface WriteContext extends ReadMergeModifyContext { fieldNodeSet: Set; } >; - // Directive metadata for @client and @defer. We could use a bitfield for this + // Directive metadata for @client, @defer, and @stream. We could use a bitfield for this // information to save some space, and use that bitfield number as the keys in // the context.flavors Map. clientOnly: boolean; deferred: boolean; + streamed: boolean; flavors: Map; } type FlavorableWriteContext = Pick< WriteContext, - "clientOnly" | "deferred" | "flavors" + "clientOnly" | "deferred" | "streamed" | "flavors" >; // Since there are only four possible combinations of context.clientOnly and @@ -100,7 +101,8 @@ type FlavorableWriteContext = Pick< function getContextFlavor( context: TContext, clientOnly: TContext["clientOnly"], - deferred: TContext["deferred"] + deferred: TContext["deferred"], + streamed: TContext["streamed"] ): TContext { const key = `${clientOnly}${deferred}`; let flavored = context.flavors.get(key); @@ -108,12 +110,17 @@ function getContextFlavor( context.flavors.set( key, (flavored = - context.clientOnly === clientOnly && context.deferred === deferred ? + ( + context.clientOnly === clientOnly && + context.deferred === deferred && + context.streamed === streamed + ) ? context : { ...context, clientOnly, deferred, + streamed, }) ); } @@ -169,6 +176,7 @@ export class StoreWriter { incomingById: new Map(), clientOnly: false, deferred: false, + streamed: false, flavors: new Map(), }; @@ -352,7 +360,7 @@ export class StoreWriter { // Reset context.clientOnly and context.deferred to their default // values before processing nested selection sets. field.selectionSet ? - getContextFlavor(context, false, false) + getContextFlavor(context, false, false, false) : context, childTree ); @@ -395,6 +403,7 @@ export class StoreWriter { __DEV__ && !context.clientOnly && !context.deferred && + !context.streamed && !addTypenameToDocument.added(field) && // If the field has a read function, it may be a synthetic field or // provide a default value, so its absence from the written data should @@ -522,6 +531,7 @@ export class StoreWriter { WriteContext, | "clientOnly" | "deferred" + | "streamed" | "flavors" | "fragmentMap" | "lookupFragment" @@ -555,12 +565,13 @@ export class StoreWriter { ) { const visitedNode = limitingTrie.lookup( selectionSet, - // Because we take inheritedClientOnly and inheritedDeferred into + // Because we take inheritedClientOnly, inheritedDeferred, and inheritedStramed into // consideration here (in addition to selectionSet), it's possible for // the same selection set to be flattened more than once, if it appears // in the query with different @client and/or @directive configurations. inheritedContext.clientOnly, - inheritedContext.deferred + inheritedContext.deferred, + inheritedContext.streamed ); if (visitedNode.visited) return; visitedNode.visited = true; @@ -568,12 +579,12 @@ export class StoreWriter { selectionSet.selections.forEach((selection) => { if (!shouldInclude(selection, context.variables)) return; - let { clientOnly, deferred } = inheritedContext; + let { clientOnly, deferred, streamed } = inheritedContext; if ( - // Since the presence of @client or @defer on this field can only - // cause clientOnly or deferred to become true, we can skip the - // forEach loop if both clientOnly and deferred are already true. - !(clientOnly && deferred) && + // Since the presence of @client, @defer, or @stream on this field can only + // cause clientOnly, deferred, or streamed to become true, we can skip the + // forEach loop if clientOnly, deferred, and streamed are already true. + !(clientOnly && deferred && streamed) && isNonEmptyArray(selection.directives) ) { selection.directives.forEach((dir) => { @@ -591,6 +602,18 @@ export class StoreWriter { // TODO In the future, we may want to record args.label using // context.deferred, if a label is specified. } + if (name === "stream") { + const args = argumentsObjectFromField(dir, context.variables); + // The @stream directive takes an optional args.if boolean + // argument, similar to @include(if: boolean). Note that + // @stream(if: false) does not make context.deferred false, but + // instead behaves as if there was no @stream directive. + if (!args || (args as { if?: boolean }).if !== false) { + streamed = true; + } + // TODO In the future, we may want to record args.label using + // context.deferred, if a label is specified. + } }); } @@ -602,11 +625,12 @@ export class StoreWriter { // to true only if *all* paths have the directive (hence the &&). clientOnly = clientOnly && existing.clientOnly; deferred = deferred && existing.deferred; + streamed = streamed && existing.streamed; } fieldMap.set( selection, - getContextFlavor(context, clientOnly, deferred) + getContextFlavor(context, clientOnly, deferred, streamed) ); } else { const fragment = getFragmentFromSelection( @@ -632,7 +656,7 @@ export class StoreWriter { ) { flatten( fragment.selectionSet, - getContextFlavor(context, clientOnly, deferred) + getContextFlavor(context, clientOnly, deferred, streamed) ); } } From 8c864795df13d667d11bb02623d24fca50cc70cf Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:12:02 -0600 Subject: [PATCH 11/97] Add tests for stream with the full client --- .../__tests__/graphql17Alpha9/stream.test.ts | 286 +++++++++++++++++- 1 file changed, 285 insertions(+), 1 deletion(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 56a519d258f..d3e63f7e327 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -15,9 +15,22 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha9"; +import { from } from "rxjs"; -import { gql } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, +} 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 @@ -131,6 +144,12 @@ async function* run( } } +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 @@ -2375,3 +2394,268 @@ describe("graphql-js test cases", () => { // not interesting from a client perspective }); }); + +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("handles streamed scalar lists", async () => { + const client = new ApolloClient({ + link: createSchemaLink({ 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({ + loading: true, + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + 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: createSchemaLink({ + 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(); +}); From f82c7851204162d11ae66143f2751fbd477a357a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:17:46 -0600 Subject: [PATCH 12/97] Move most stream tests to client.watchQuery folder --- .../streamGraphql17Alpha9.test.ts | 339 ++++++++++++++++++ .../__tests__/graphql17Alpha9/stream.test.ts | 216 +---------- 2 files changed, 341 insertions(+), 214 deletions(-) create mode 100644 src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts 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..80eb5e1a382 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -0,0 +1,339 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha9"; +import { + experimentalExecuteIncrementally, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha9"; +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; +import { + markAsStreaming, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, +} from "@apollo/client/testing/internal"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + 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", +}); + +const schema = new GraphQLSchema({ query }); + +async function* run( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false +): AsyncGenerator< + | FormattedInitialIncrementalExecutionResult + | FormattedSubsequentIncrementalExecutionResult + | FormattedExecutionResult, + void +> { + const result = await experimentalExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ("initialResult" in result) { + yield JSON.parse(JSON.stringify(result.initialResult)); + + for await (const patch of result.subsequentResults) { + yield JSON.parse(JSON.stringify(patch)); + } + } else { + yield JSON.parse(JSON.stringify(result)); + } +} + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(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({ + loading: true, + data: markAsStreaming({ + scalarList: ["apple"], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + await expect(observableStream).toEmitTypedValue({ + data: { + scalarList: ["apple", "banana", "orange"], + }, + 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(); +}); diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index d3e63f7e327..64d5ca817ae 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2395,6 +2395,8 @@ describe("graphql-js test cases", () => { }); }); +// 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 }), @@ -2445,217 +2447,3 @@ test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { partial: false, }); }); - -test("handles streamed scalar lists", async () => { - const client = new ApolloClient({ - link: createSchemaLink({ 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({ - loading: true, - data: markAsStreaming({ - scalarList: ["apple"], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - partial: true, - }); - - await expect(observableStream).toEmitTypedValue({ - data: { - scalarList: ["apple", "banana", "orange"], - }, - 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: createSchemaLink({ - 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(); -}); From 51b0bc3780123dab8ddc2228edf819353e5423c9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:21:58 -0600 Subject: [PATCH 13/97] Fix issue with frozen arrays --- src/incremental/handlers/graphql17Alpha9.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 91b6d0a6fd7..b3638675b8b 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -112,22 +112,32 @@ class IncrementalRequest const path = pending.path.concat(incremental.subPath ?? []); if ("items" in incremental) { - const array = path.reduce( + let data = path.reduce( // Use `&&` to maintain `null` if encountered (data, key) => data && data[key], this.data ); invariant( - Array.isArray(array), + Array.isArray(data), `@stream: value at path %o is not an array. Please file an issue for the Apollo Client team to investigate.`, path ); - array.push(...(incremental.items as ReadonlyArray)); + if (data) { + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = + i === path.length - 1 ? data.concat(incremental.items) : data; + data = parent as typeof data; + } + } this.merge( { + data, extensions: incremental.extensions, errors: incremental.errors, }, From ab5b085521a27a3c5e8ae7768ff755b788a6d0c3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:23:13 -0600 Subject: [PATCH 14/97] Simplify --- src/incremental/handlers/graphql17Alpha9.ts | 72 ++++++--------------- 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index b3638675b8b..8062549bf15 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -110,59 +110,29 @@ class IncrementalRequest ); const path = pending.path.concat(incremental.subPath ?? []); - - if ("items" in incremental) { - let data = path.reduce( - // Use `&&` to maintain `null` if encountered - (data, key) => data && data[key], - this.data - ); - - invariant( - Array.isArray(data), - `@stream: value at path %o is not an array. Please file an issue for the Apollo Client team to investigate.`, + let data = + "items" in incremental ? path - ); - - if (data) { - for (let i = path.length - 1; i >= 0; i--) { - const key = path[i]; - const parent: Record = - typeof key === "number" ? [] : {}; - parent[key] = - i === path.length - 1 ? data.concat(incremental.items) : data; - data = parent as typeof data; - } - } - - this.merge( - { - data, - extensions: incremental.extensions, - errors: incremental.errors, - }, - new DeepMerger() - ); - } else { - let { data } = incremental; - - for (let i = path.length - 1; i >= 0; i--) { - const key = path[i]; - const parent: Record = - typeof key === "number" ? [] : {}; - parent[key] = data; - data = parent as typeof data; - } - - this.merge( - { - data: data as TData, - extensions: incremental.extensions, - errors: incremental.errors, - }, - new DeepMerger() - ); + .reduce((data, key) => data[key], this.data) + .concat(incremental.items) + : incremental.data; + + for (let i = path.length - 1; i >= 0; i--) { + const key = path[i]; + const parent: Record = + typeof key === "number" ? [] : {}; + parent[key] = data; + data = parent as typeof data; } + + this.merge( + { + data, + extensions: incremental.extensions, + errors: incremental.errors, + }, + new DeepMerger() + ); } } From 2dda3d1ad26a8a1aa9ea3ce9d63d63343943a634 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:34:37 -0600 Subject: [PATCH 15/97] Revert "Add necessary changes to writeToStore to handle stream" This reverts commit dba0d09b0f2758fe7628e151c5f8f74399bcb200. --- src/cache/inmemory/writeToStore.ts | 52 ++++++++---------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 761d6e54ca6..b44b6eb02f6 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -79,18 +79,17 @@ export interface WriteContext extends ReadMergeModifyContext { fieldNodeSet: Set; } >; - // Directive metadata for @client, @defer, and @stream. We could use a bitfield for this + // Directive metadata for @client and @defer. We could use a bitfield for this // information to save some space, and use that bitfield number as the keys in // the context.flavors Map. clientOnly: boolean; deferred: boolean; - streamed: boolean; flavors: Map; } type FlavorableWriteContext = Pick< WriteContext, - "clientOnly" | "deferred" | "streamed" | "flavors" + "clientOnly" | "deferred" | "flavors" >; // Since there are only four possible combinations of context.clientOnly and @@ -101,8 +100,7 @@ type FlavorableWriteContext = Pick< function getContextFlavor( context: TContext, clientOnly: TContext["clientOnly"], - deferred: TContext["deferred"], - streamed: TContext["streamed"] + deferred: TContext["deferred"] ): TContext { const key = `${clientOnly}${deferred}`; let flavored = context.flavors.get(key); @@ -110,17 +108,12 @@ function getContextFlavor( context.flavors.set( key, (flavored = - ( - context.clientOnly === clientOnly && - context.deferred === deferred && - context.streamed === streamed - ) ? + context.clientOnly === clientOnly && context.deferred === deferred ? context : { ...context, clientOnly, deferred, - streamed, }) ); } @@ -176,7 +169,6 @@ export class StoreWriter { incomingById: new Map(), clientOnly: false, deferred: false, - streamed: false, flavors: new Map(), }; @@ -360,7 +352,7 @@ export class StoreWriter { // Reset context.clientOnly and context.deferred to their default // values before processing nested selection sets. field.selectionSet ? - getContextFlavor(context, false, false, false) + getContextFlavor(context, false, false) : context, childTree ); @@ -403,7 +395,6 @@ export class StoreWriter { __DEV__ && !context.clientOnly && !context.deferred && - !context.streamed && !addTypenameToDocument.added(field) && // If the field has a read function, it may be a synthetic field or // provide a default value, so its absence from the written data should @@ -531,7 +522,6 @@ export class StoreWriter { WriteContext, | "clientOnly" | "deferred" - | "streamed" | "flavors" | "fragmentMap" | "lookupFragment" @@ -565,13 +555,12 @@ export class StoreWriter { ) { const visitedNode = limitingTrie.lookup( selectionSet, - // Because we take inheritedClientOnly, inheritedDeferred, and inheritedStramed into + // Because we take inheritedClientOnly and inheritedDeferred into // consideration here (in addition to selectionSet), it's possible for // the same selection set to be flattened more than once, if it appears // in the query with different @client and/or @directive configurations. inheritedContext.clientOnly, - inheritedContext.deferred, - inheritedContext.streamed + inheritedContext.deferred ); if (visitedNode.visited) return; visitedNode.visited = true; @@ -579,12 +568,12 @@ export class StoreWriter { selectionSet.selections.forEach((selection) => { if (!shouldInclude(selection, context.variables)) return; - let { clientOnly, deferred, streamed } = inheritedContext; + let { clientOnly, deferred } = inheritedContext; if ( - // Since the presence of @client, @defer, or @stream on this field can only - // cause clientOnly, deferred, or streamed to become true, we can skip the - // forEach loop if clientOnly, deferred, and streamed are already true. - !(clientOnly && deferred && streamed) && + // Since the presence of @client or @defer on this field can only + // cause clientOnly or deferred to become true, we can skip the + // forEach loop if both clientOnly and deferred are already true. + !(clientOnly && deferred) && isNonEmptyArray(selection.directives) ) { selection.directives.forEach((dir) => { @@ -602,18 +591,6 @@ export class StoreWriter { // TODO In the future, we may want to record args.label using // context.deferred, if a label is specified. } - if (name === "stream") { - const args = argumentsObjectFromField(dir, context.variables); - // The @stream directive takes an optional args.if boolean - // argument, similar to @include(if: boolean). Note that - // @stream(if: false) does not make context.deferred false, but - // instead behaves as if there was no @stream directive. - if (!args || (args as { if?: boolean }).if !== false) { - streamed = true; - } - // TODO In the future, we may want to record args.label using - // context.deferred, if a label is specified. - } }); } @@ -625,12 +602,11 @@ export class StoreWriter { // to true only if *all* paths have the directive (hence the &&). clientOnly = clientOnly && existing.clientOnly; deferred = deferred && existing.deferred; - streamed = streamed && existing.streamed; } fieldMap.set( selection, - getContextFlavor(context, clientOnly, deferred, streamed) + getContextFlavor(context, clientOnly, deferred) ); } else { const fragment = getFragmentFromSelection( @@ -656,7 +632,7 @@ export class StoreWriter { ) { flatten( fragment.selectionSet, - getContextFlavor(context, clientOnly, deferred, streamed) + getContextFlavor(context, clientOnly, deferred) ); } } From 292b5b3632ae3ab00b5bd3e8c5bca5d884e15df5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:45:44 -0600 Subject: [PATCH 16/97] Don't add stream directive to field name --- src/utilities/internal/storeKeyNameFromField.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utilities/internal/storeKeyNameFromField.ts b/src/utilities/internal/storeKeyNameFromField.ts index e522cbfc832..cc7496ec9bb 100644 --- a/src/utilities/internal/storeKeyNameFromField.ts +++ b/src/utilities/internal/storeKeyNameFromField.ts @@ -12,6 +12,8 @@ export function storeKeyNameFromField( if (field.directives) { directivesObj = {}; field.directives.forEach((directive) => { + if (directive.name.value === "stream") return; + directivesObj[directive.name.value] = {}; if (directive.arguments) { From 17b0bafc2a0d480caaf20d4a30a344fdb144afe0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 15:58:49 -0600 Subject: [PATCH 17/97] Add test for writing stream field to cache --- src/cache/inmemory/__tests__/cache.ts | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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", () => { From 64ed794245b40974556b5341000b08096d474dd7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:01:19 -0600 Subject: [PATCH 18/97] Add stream to known directives --- src/utilities/internal/getStoreKeyName.ts | 1 + 1 file changed, 1 insertion(+) 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 From 0bea917cea4cba1607d41b242351cb76ba9a8bb9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:02:09 -0600 Subject: [PATCH 19/97] Remove check in storeKeyNameFromField --- src/utilities/internal/storeKeyNameFromField.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utilities/internal/storeKeyNameFromField.ts b/src/utilities/internal/storeKeyNameFromField.ts index cc7496ec9bb..e522cbfc832 100644 --- a/src/utilities/internal/storeKeyNameFromField.ts +++ b/src/utilities/internal/storeKeyNameFromField.ts @@ -12,8 +12,6 @@ export function storeKeyNameFromField( if (field.directives) { directivesObj = {}; field.directives.forEach((directive) => { - if (directive.name.value === "stream") return; - directivesObj[directive.name.value] = {}; if (directive.arguments) { From 7853aa8c54f2e200d202832189de1ec793cda9de Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:03:50 -0600 Subject: [PATCH 20/97] Remove unused imports --- .../handlers/__tests__/graphql17Alpha9/stream.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 64d5ca817ae..de6ea29806a 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -20,7 +20,6 @@ import { from } from "rxjs"; import { ApolloClient, ApolloLink, - CombinedGraphQLErrors, gql, InMemoryCache, NetworkStatus, @@ -28,7 +27,6 @@ import { import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { markAsStreaming, - mockDeferStreamGraphQL17Alpha9, ObservableStream, } from "@apollo/client/testing/internal"; From 240bf89845fcaeb64b5f701bc98f4996943cc998 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:12:58 -0600 Subject: [PATCH 21/97] Add more tests for different scenarios --- .../streamGraphql17Alpha9.test.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 80eb5e1a382..7d0872fcf2f 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -168,6 +168,60 @@ test("handles streamed scalar lists", async () => { }); }); +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({ + loading: true, + data: markAsStreaming({ + scalarListList: [["apple", "apple", "apple"]], + }), + dataState: "streaming", + 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({ @@ -337,3 +391,189 @@ test("handles errors from items before initialCount is reached", async () => { 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).toEmitTypedValue({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(observableStream).not.toEmitAnything(); +}); From 91e7e3c86652e174a6d7c6f53aece9258175dfe6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:18:56 -0600 Subject: [PATCH 22/97] Rerun api report --- .api-reports/api-report-utilities_internal.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From d9d657e912374e604bd1e82dd9d34d8b7b1dc66d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:23:17 -0600 Subject: [PATCH 23/97] Formatting --- .../__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 7d0872fcf2f..07e4d651bfe 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -148,11 +148,11 @@ test("handles streamed scalar lists", async () => { }); await expect(observableStream).toEmitTypedValue({ - loading: true, data: markAsStreaming({ scalarList: ["apple"], }), dataState: "streaming", + loading: true, networkStatus: NetworkStatus.streaming, partial: true, }); @@ -198,11 +198,11 @@ test("handles streamed multi-dimensional lists", async () => { }); await expect(observableStream).toEmitTypedValue({ - loading: true, data: markAsStreaming({ scalarListList: [["apple", "apple", "apple"]], }), dataState: "streaming", + loading: true, networkStatus: NetworkStatus.streaming, partial: true, }); From ce8b922368197f9a6a23ba321b55be1a4e96a248 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:26:04 -0600 Subject: [PATCH 24/97] More stream tests --- .../streamGraphql17Alpha9.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 07e4d651bfe..270ffb8cd52 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -577,3 +577,123 @@ test("handles final chunk without incremental value", async () => { 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(); +}); From 34f846bd08e4b84293085d877f17fd3ce3e245a6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:36:30 -0600 Subject: [PATCH 25/97] Use toEmitSimilarValue --- .../streamGraphql17Alpha9.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 270ffb8cd52..134adc49142 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -561,13 +561,13 @@ test("handles final chunk without incremental value", async () => { 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" }, - ], + await expect(observableStream).toEmitSimilarValue({ + expected: (previous) => ({ + ...previous, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, }), dataState: "complete", loading: false, From c0824bcd2c2c5d5109904f70df5f63e57faf7101 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:37:34 -0600 Subject: [PATCH 26/97] Add test for nested stream with defer --- .../streamGraphql17Alpha9.test.ts | 199 +++++++++++++++++- 1 file changed, 195 insertions(+), 4 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 134adc49142..6abf9939c7e 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -124,6 +124,21 @@ function createLink(rootValue?: Record) { }); } +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | Promise) => void; + reject: (reason?: any) => void; +} { + // these are assigned synchronously within the Promise constructor + 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 }; +} + test("handles streamed scalar lists", async () => { const client = new ApolloClient({ link: createLink({ scalarList: ["apple", "banana", "orange"] }), @@ -569,10 +584,6 @@ test("handles final chunk without incremental value", async () => { networkStatus: NetworkStatus.ready, partial: false, }), - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.ready, - partial: false, }); await expect(observableStream).not.toEmitAnything(); @@ -697,3 +708,183 @@ test("handles errors thrown after initialCount is reached", async () => { 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(); +}); From 65b9949b1dc4e9b4df632fc25a32a6c670b6a36d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:45:06 -0600 Subject: [PATCH 27/97] Add test for defer inside stream --- .../streamGraphql17Alpha9.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 6abf9939c7e..133c8a4d11a 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -888,3 +888,101 @@ it("handles stream when in parent deferred fragment", async () => { 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(); +}); From 923b340b896496d3221c9450d938b6e3fa0aef22 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:48:32 -0600 Subject: [PATCH 28/97] Add promiseWithResolvers to testing utils --- src/testing/internal/index.ts | 1 + src/testing/internal/promiseWithResolvers.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/testing/internal/promiseWithResolvers.ts diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index 37fad789108..070e644ad14 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,6 +23,7 @@ export { } from "./scenarios/index.js"; export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.js"; +export { promiseWithResolvers } from "./promiseWithResolvers.js"; export { renderAsync } from "./rtl/renderAsync.js"; export { renderHookAsync } from "./rtl/renderHookAsync.js"; export { mockDefer20220824 } from "./multipart/mockDefer20220824.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 }; +} From 5050441806c939035451e70dd5d0bc420b069077 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 16:50:09 -0600 Subject: [PATCH 29/97] Use shared promiseWithResolvers helper --- .../streamGraphql17Alpha9.test.ts | 16 +--------------- .../__tests__/graphql17Alpha9/defer.test.ts | 18 +----------------- .../__tests__/graphql17Alpha9/stream.test.ts | 18 +----------------- 3 files changed, 3 insertions(+), 49 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts index 133c8a4d11a..c414da5a7bc 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts @@ -28,6 +28,7 @@ import { markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, + promiseWithResolvers, } from "@apollo/client/testing/internal"; const friendType = new GraphQLObjectType({ @@ -124,21 +125,6 @@ function createLink(rootValue?: Record) { }); } -function promiseWithResolvers(): { - promise: Promise; - resolve: (value: T | Promise) => void; - reject: (reason?: any) => void; -} { - // these are assigned synchronously within the Promise constructor - 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 }; -} - test("handles streamed scalar lists", async () => { const client = new ApolloClient({ link: createLink({ scalarList: ["apple", "banana", "orange"] }), diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts index b61e7d2d4e7..06f47d063bc 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -30,6 +30,7 @@ import { markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, + promiseWithResolvers, wait, } from "@apollo/client/testing/internal"; @@ -153,23 +154,6 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -type PromiseOrValue = Promise | T; - -function promiseWithResolvers(): { - promise: Promise; - resolve: (value: T | PromiseOrValue) => void; - reject: (reason?: any) => void; -} { - // these are assigned synchronously within the Promise constructor - let resolve!: (value: T | PromiseOrValue) => void; - let reject!: (reason?: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - async function* run( document: DocumentNode, rootValue: Record = { hero }, diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index de6ea29806a..db3beac262b 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -28,6 +28,7 @@ import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { markAsStreaming, ObservableStream, + promiseWithResolvers, } from "@apollo/client/testing/internal"; // This is the test setup of the `graphql-js` v17.0.0-alpha.9 release: @@ -97,23 +98,6 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -type PromiseOrValue = Promise | T; - -function promiseWithResolvers(): { - promise: Promise; - resolve: (value: T | PromiseOrValue) => void; - reject: (reason?: any) => void; -} { - // these are assigned synchronously within the Promise constructor - let resolve!: (value: T | PromiseOrValue) => void; - let reject!: (reason?: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - async function* run( document: DocumentNode, rootValue: unknown = {}, From fbde032e721a553b7ee6d025cf7f7a8c75a98aa6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 17:44:34 -0600 Subject: [PATCH 30/97] Temp rename --- ...reamGraphql17Alpha9.test.ts => streamGraphQL17Alpha90.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/__tests__/client.watchQuery/{streamGraphql17Alpha9.test.ts => streamGraphQL17Alpha90.test.ts} (100%) diff --git a/src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha90.test.ts similarity index 100% rename from src/core/__tests__/client.watchQuery/streamGraphql17Alpha9.test.ts rename to src/core/__tests__/client.watchQuery/streamGraphQL17Alpha90.test.ts From d32ef4316640392ba9e3093d1da4d1721b17ae3e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 17:44:49 -0600 Subject: [PATCH 31/97] Fix rename --- ...reamGraphQL17Alpha90.test.ts => streamGraphQL17Alpha9.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/__tests__/client.watchQuery/{streamGraphQL17Alpha90.test.ts => streamGraphQL17Alpha9.test.ts} (100%) diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha90.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts similarity index 100% rename from src/core/__tests__/client.watchQuery/streamGraphQL17Alpha90.test.ts rename to src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts From 2b336ce9f11758b5512e0eea841c8f2214db65fe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 18:46:54 -0600 Subject: [PATCH 32/97] Add tests for stream with defer20220824 --- .../__tests__/defer20220824/stream.test.ts | 1733 +++++++++++++++++ 1 file changed, 1733 insertions(+) create mode 100644 src/incremental/handlers/__tests__/defer20220824/stream.test.ts 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..ab7c27a0c1c --- /dev/null +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -0,0 +1,1733 @@ +import assert from "node:assert"; + +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha2"; +import { + experimentalExecuteIncrementally, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha2"; +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 { + 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 friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + 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", +}); + +const schema = new GraphQLSchema({ query }); + +async function* run( + 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)); + } +} + +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"], + }, + }); + expect(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"], + }, + }); + expect(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"], + }, + }); + expect(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"], + ], + }, + }); + expect(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(true); + } + }); + + 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", + locations: [{ line: 3, column: 9 }], + 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", + locations: [{ line: 3, column: 9 }], + 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], + }, + errors: [ + { + message: "bad", + locations: [{ line: 3, column: 9 }], + 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", + locations: [{ line: 3, column: 9 }], + 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(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, + }); +}); From 41ae9819335438091936e60db2e467416ca9df79 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 18:48:08 -0600 Subject: [PATCH 33/97] Move defer20220824 defer tests to subfolder --- .../{defer20220824.test.ts => defer20220824/defer.test.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/incremental/handlers/__tests__/{defer20220824.test.ts => defer20220824/defer.test.ts} (99%) diff --git a/src/incremental/handlers/__tests__/defer20220824.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts similarity index 99% rename from src/incremental/handlers/__tests__/defer20220824.test.ts rename to src/incremental/handlers/__tests__/defer20220824/defer.test.ts index e412199e2a6..51f2eb9c874 100644 --- a/src/incremental/handlers/__tests__/defer20220824.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -35,7 +35,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 From aa3924ad24720767689f3c9bc1231344635f503e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 18:54:22 -0600 Subject: [PATCH 34/97] Update types for defer20220824 handler --- src/incremental/handlers/defer20220824.ts | 27 +++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 0ed7cd97f59..ba3008c7c57 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> From 020ba183f7b3ecd3cac4b1a818bdfdf0865dc699 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:25:16 -0600 Subject: [PATCH 35/97] Fix assertion on test --- .../handlers/__tests__/defer20220824/stream.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index ab7c27a0c1c..ca21dc4996a 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -153,19 +153,6 @@ describe("Execute: stream directive", () => { expect(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(); From 4fbec80532bccf0109aad4684fc9c7004e2efc63 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:29:39 -0600 Subject: [PATCH 36/97] First pass at implementing stream for old format --- src/incremental/handlers/defer20220824.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index ba3008c7c57..5dd0b9cea50 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -102,7 +102,12 @@ class DeferRequest> if (hasIncrementalChunks(chunk)) { const merger = new DeepMerger(); for (const incremental of chunk.incremental) { - let { data, path, errors, extensions } = incremental; + const { path, errors, extensions } = incremental; + let data = + "items" in incremental ? incremental.items?.[0] + : "data" in incremental ? incremental.data + : undefined; + if (data && path) { for (let i = path.length - 1; i >= 0; --i) { const key = path[i]; From d6a051b4d9cb598bd091783fa371997430a25c86 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:31:57 -0600 Subject: [PATCH 37/97] Fix more incorrect assertions --- .../__tests__/defer20220824/stream.test.ts | 44 +------------------ 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index ca21dc4996a..3d8ca8294ca 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -193,32 +193,6 @@ describe("Execute: stream directive", () => { expect(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"], - }, - }); - expect(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(); @@ -330,22 +304,6 @@ describe("Execute: stream directive", () => { expect(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"], - ], - }, - }); - expect(request.hasNext).toBe(true); - } - { const { value: chunk, done } = await incoming.next(); @@ -494,7 +452,7 @@ describe("Execute: stream directive", () => { ], }, }); - expect(request.hasNext).toBe(true); + expect(request.hasNext).toBe(false); } }); From 99bc51f647e93e057dceec6ba0879deaec81cabf Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:34:19 -0600 Subject: [PATCH 38/97] Remove locations from errors in assertions --- .../handlers/__tests__/defer20220824/stream.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index 3d8ca8294ca..b650584725d 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -491,7 +491,6 @@ describe("Execute: stream directive", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -515,7 +514,6 @@ describe("Execute: stream directive", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -572,7 +570,6 @@ describe("Execute: stream directive", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], @@ -596,7 +593,6 @@ describe("Execute: stream directive", () => { errors: [ { message: "bad", - locations: [{ line: 3, column: 9 }], path: ["friendList", 1], }, ], From 347eb206ee4379946653d782778497561e78ad0b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:39:57 -0600 Subject: [PATCH 39/97] Handle merging null from stream --- src/incremental/handlers/defer20220824.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 5dd0b9cea50..16ca682cd6a 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -104,11 +104,15 @@ class DeferRequest> for (const incremental of chunk.incremental) { 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] - : "data" in incremental ? incremental.data + // Ensure `data: null` isn't merged for `@defer` responses by + // falling back to `undefined` + : "data" in incremental ? incremental.data ?? undefined : undefined; - if (data && path) { + if (data !== undefined && path) { for (let i = path.length - 1; i >= 0; --i) { const key = path[i]; const isNumericKey = !isNaN(+key); From 0d271efc5000438c366f7ab0926af89d43fc4c52 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:41:13 -0600 Subject: [PATCH 40/97] Fix more incorrect assertions --- .../__tests__/defer20220824/stream.test.ts | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index b650584725d..485c8a69b69 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -558,25 +558,6 @@ describe("Execute: stream directive", () => { expect(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(); @@ -1131,28 +1112,6 @@ describe("Execute: stream directive", () => { expect(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(); From 7d1cc64db1f3669189ecde99dfd1b770329c8113 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:44:38 -0600 Subject: [PATCH 41/97] Initialize merger on class initialization. Rename to merge --- src/incremental/handlers/defer20220824.ts | 25 ++++++++--------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 16ca682cd6a..07a9fcb7888 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -74,13 +74,11 @@ class DeferRequest> private errors: Array = []; private extensions: Record = {}; private data: any = {}; + private merger = new DeepMerger(); - private mergeIn( - normalized: FormattedExecutionResult, - merger: DeepMerger - ) { + private merge(normalized: FormattedExecutionResult) { if (normalized.data !== undefined) { - this.data = merger.merge(this.data, normalized.data); + this.data = this.merger.merge(this.data, normalized.data); } if (normalized.errors) { this.errors.push(...normalized.errors); @@ -96,11 +94,9 @@ 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) { const { path, errors, extensions } = incremental; let data = @@ -121,14 +117,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, + }); } } From 85a1dcca9dfec161c4d2982eb2184419c8af4e46 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 19:49:11 -0600 Subject: [PATCH 42/97] Add test file for client.watchQuery with stream on old format --- .../streamDefer20220824.test.ts | 971 ++++++++++++++++++ 1 file changed, 971 insertions(+) create mode 100644 src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts 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..ea598575307 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -0,0 +1,971 @@ +import type { + FormattedExecutionResult, + FormattedInitialIncrementalExecutionResult, + FormattedSubsequentIncrementalExecutionResult, +} from "graphql-17-alpha2"; +import { + experimentalExecuteIncrementally, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "graphql-17-alpha2"; +import { from } from "rxjs"; + +import type { DocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { Defer20220824Handler } from "@apollo/client/incremental"; +import { + markAsStreaming, + mockDefer20220824, + ObservableStream, + promiseWithResolvers, +} from "@apollo/client/testing/internal"; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: "Friend", +}); + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + 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", +}); + +const schema = new GraphQLSchema({ query }); + +async function* run( + 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)); + } +} + +function createLink(rootValue?: Record) { + return new ApolloLink((operation) => { + return from(run(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], + }), + 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 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: 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 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" }], + }, + 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 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", 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(); +}); From 73d7da26ad442891db5ebfab5953a21061135b10 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 22:03:24 -0600 Subject: [PATCH 43/97] Set test to failing to determine what we should do later --- .../streamDefer20220824.test.ts | 117 ++++++++++-------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts index ea598575307..fd19b925fd4 100644 --- a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -692,70 +692,79 @@ test("handles errors thrown after initialCount is reached", async () => { 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 Defer20220824Handler(), - }); - - const query = gql` - query { - nonNullFriendList @stream(initialCount: 1) { - id - name +// 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" }) - ); + 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: 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: 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({ + await expect(observableStream).toEmitTypedValue({ data: { nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], }, - errors: [ - { - message: - "Cannot return null for non-nullable field Query.nonNullFriendList.", - path: ["nonNullFriendList", 1], + error: new CombinedGraphQLErrors({ + data: { + nonNullFriendList: [{ __typename: "Friend", id: "1", name: "Luke" }], }, - ], - }), - dataState: "complete", - loading: false, - networkStatus: NetworkStatus.error, - partial: false, - }); + 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(); -}); + await expect(observableStream).not.toEmitAnything(); + } +); it("handles stream when in parent deferred fragment", async () => { const { promise: slowFieldPromise, resolve: resolveSlowField } = From 6271687310776bf404d0cf700a07e6bb9e38178a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 22:07:32 -0600 Subject: [PATCH 44/97] Update assertions based on behavior of old implementation --- .../streamDefer20220824.test.ts | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts index fd19b925fd4..74564909312 100644 --- a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -437,22 +437,6 @@ test("handles errors from items after initialCount is reached", async () => { 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: [ @@ -607,14 +591,16 @@ test("handles errors thrown before initialCount is reached", async () => { await expect(observableStream).toEmitTypedValue({ data: { - friendList: null, + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], }, error: new CombinedGraphQLErrors({ - data: { friendList: null }, + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, errors: [ { message: "bad", - path: ["friendList"], + path: ["friendList", 1], }, ], }), @@ -672,14 +658,16 @@ test("handles errors thrown after initialCount is reached", async () => { await expect(observableStream).toEmitTypedValue({ data: { - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], }, error: new CombinedGraphQLErrors({ - data: { friendList: [{ __typename: "Friend", id: "1", name: "Luke" }] }, + data: { + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], + }, errors: [ { message: "bad", - path: ["friendList"], + path: ["friendList", 1], }, ], }), @@ -942,7 +930,7 @@ test("handles @defer inside @stream", async () => { expected: (previous) => ({ ...previous, data: markAsStreaming({ - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + friendList: [{ __typename: "Friend", id: "1" }], }), dataState: "streaming", }), From 3ac5544c012b1b03279fc8698665dfd21f72f4e1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 22:33:12 -0600 Subject: [PATCH 45/97] Rerun api report --- .api-reports/api-report-incremental.api.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 { From 562e2191a4b38e05edb3da9074e2958db3c7b6b9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 22:48:01 -0600 Subject: [PATCH 46/97] Add changeset --- .changeset/six-islands-drum.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/six-islands-drum.md 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. From 9dcbd37c1ca18e21f3211bc241456e5814d753fa Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 23:56:35 -0600 Subject: [PATCH 47/97] Update size limits --- .size-limits.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 } From 3aa091cf29a61667e1cf1f9beb6c0c8445cddd20 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 22:59:47 -0600 Subject: [PATCH 48/97] Ensure multipart/mixed header is set when using stream --- src/incremental/handlers/defer20220824.ts | 2 +- src/incremental/handlers/graphql17Alpha9.ts | 2 +- src/link/http/__tests__/HttpLink.ts | 78 +++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 07a9fcb7888..378f92adc1b 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -177,7 +177,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 8062549bf15..58ba0b79cae 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -193,7 +193,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..489ab4829f2 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -57,6 +57,15 @@ const sampleDeferredQuery = gql` } `; +const sampleStreamedQuery = gql` + query SampleDeferredQuery { + stubs @stream { + id + name + } + } +`; + const sampleQueryCustomDirective = gql` query SampleDeferredQuery { stub { @@ -1341,6 +1350,23 @@ describe("HttpLink", () => { "-----", ].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 finalChunkOnlyHasNextFalse = [ "--graphql", "content-type: application/json", @@ -1524,6 +1550,58 @@ describe("HttpLink", () => { ); }); + 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", + }, + }) + ); + }); + // 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 () => { From 802c7d98da4ed4c03f29e7a9cbfe07662d035332 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 23:08:47 -0600 Subject: [PATCH 49/97] Add tests for alpha.9 in HttpLink --- src/link/http/__tests__/HttpLink.ts | 150 +++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 489ab4829f2..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 { @@ -1350,6 +1353,23 @@ 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", @@ -1367,6 +1387,23 @@ describe("HttpLink", () => { "-----", ].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", @@ -1550,6 +1587,61 @@ 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") @@ -1602,6 +1694,62 @@ describe("HttpLink", () => { ); }); + 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 () => { From 86e0025f7ef7948ecf164d72c080beb23971c370 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 23:12:47 -0600 Subject: [PATCH 50/97] Update exports snapshot --- src/__tests__/__snapshots__/exports.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 9207e712cdd..f043e84aad4 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -366,6 +366,7 @@ Array [ "mockDefer20220824", "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "promiseWithResolvers", "renderAsync", "renderHookAsync", "resetApolloContext", From c398a5549a311e255f4cbbd5b1ed452d41724692 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 9 Sep 2025 23:40:10 -0600 Subject: [PATCH 51/97] Always create a new DeepMerger --- src/incremental/handlers/defer20220824.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index 378f92adc1b..bfa9a68d73b 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -74,11 +74,13 @@ class DeferRequest> private errors: Array = []; private extensions: Record = {}; private data: any = {}; - private merger = new DeepMerger(); - private merge(normalized: FormattedExecutionResult) { + private merge( + normalized: FormattedExecutionResult, + merger: DeepMerger + ) { if (normalized.data !== undefined) { - this.data = this.merger.merge(this.data, normalized.data); + this.data = merger.merge(this.data, normalized.data); } if (normalized.errors) { this.errors.push(...normalized.errors); @@ -94,7 +96,7 @@ class DeferRequest> ): FormattedExecutionResult { this.hasNext = chunk.hasNext; this.data = cacheData; - this.merge(chunk); + this.merge(chunk, new DeepMerger()); if (hasIncrementalChunks(chunk)) { for (const incremental of chunk.incremental) { @@ -117,11 +119,14 @@ class DeferRequest> data = parent as typeof data; } } - this.merge({ - errors, - extensions, - data: data ? (data as TData) : undefined, - }); + this.merge( + { + errors, + extensions, + data: data ? (data as TData) : undefined, + }, + new DeepMerger() + ); } } From b204d224d5d50dd53530e69dc511402bcf856fdb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 14:21:36 -0600 Subject: [PATCH 52/97] Add test helpers to execute schema incrementally --- .../executeSchemaGraphQL17Alpha2.ts | 36 +++++++++++++++++++ .../executeSchemaGraphQL17Alpha9.ts | 36 +++++++++++++++++++ src/testing/internal/index.ts | 2 ++ 3 files changed, 74 insertions(+) create mode 100644 src/testing/internal/incremental/executeSchemaGraphQL17Alpha2.ts create mode 100644 src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts 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..ebeee5ebc47 --- /dev/null +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts @@ -0,0 +1,36 @@ +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 = {} +): 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/index.ts b/src/testing/internal/index.ts index 070e644ad14..f08e6b027bd 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,6 +23,8 @@ export { } from "./scenarios/index.js"; export { createClientWrapper, createMockWrapper } from "./renderHelpers.js"; export { actAsync } from "./rtl/actAsync.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"; From 2f620ef84b274e6d8708ee0152b85dc44211fcf1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 14:32:13 -0600 Subject: [PATCH 53/97] Add enableEarlyExecution option --- .../internal/incremental/executeSchemaGraphQL17Alpha9.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts index ebeee5ebc47..8285297367b 100644 --- a/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts +++ b/src/testing/internal/incremental/executeSchemaGraphQL17Alpha9.ts @@ -11,7 +11,8 @@ import type { DocumentNode } from "@apollo/client"; export async function* executeSchemaGraphQL17Alpha9( schema: GraphQLSchema, document: DocumentNode, - rootValue: unknown = {} + rootValue: unknown = {}, + enableEarlyExecution?: boolean ): AsyncGenerator< | FormattedInitialIncrementalExecutionResult | FormattedSubsequentIncrementalExecutionResult @@ -22,6 +23,7 @@ export async function* executeSchemaGraphQL17Alpha9( schema, document, rootValue, + enableEarlyExecution, }); if ("initialResult" in result) { From 6b4156cc87f30b47e5e8b498f7cfdc830b9751fe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 14:33:46 -0600 Subject: [PATCH 54/97] Update existing tests to use new execute helpers --- .../streamDefer20220824.test.ts | 38 ++--------------- .../streamGraphQL17Alpha9.test.ts | 40 ++---------------- .../__tests__/defer20220824/defer.test.ts | 35 ++-------------- .../__tests__/defer20220824/stream.test.ts | 33 ++------------- .../__tests__/graphql17Alpha9/defer.test.ts | 42 ++++--------------- .../__tests__/graphql17Alpha9/stream.test.ts | 34 ++++----------- 6 files changed, 31 insertions(+), 191 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts index 74564909312..5c842c3db2b 100644 --- a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -1,10 +1,4 @@ -import type { - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha2"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -14,7 +8,6 @@ import { } from "graphql-17-alpha2"; import { from } from "rxjs"; -import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -25,6 +18,7 @@ import { } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, mockDefer20220824, ObservableStream, @@ -91,35 +85,11 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -async function* run( - 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)); - } -} - function createLink(rootValue?: Record) { return new ApolloLink((operation) => { - return from(run(operation.query, rootValue)); + return from( + executeSchemaGraphQL17Alpha2(schema, operation.query, rootValue) + ); }); } diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts index c414da5a7bc..c41861e597e 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -1,10 +1,4 @@ -import type { - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha9"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -14,7 +8,6 @@ import { } from "graphql-17-alpha9"; import { from } from "rxjs"; -import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -25,6 +18,7 @@ import { } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha9, markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, @@ -91,37 +85,11 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -async function* run( - document: DocumentNode, - rootValue: unknown = {}, - enableEarlyExecution = false -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - | FormattedExecutionResult, - void -> { - const result = await experimentalExecuteIncrementally({ - schema, - document, - rootValue, - enableEarlyExecution, - }); - - if ("initialResult" in result) { - yield JSON.parse(JSON.stringify(result.initialResult)); - - for await (const patch of result.subsequentResults) { - yield JSON.parse(JSON.stringify(patch)); - } - } else { - yield JSON.parse(JSON.stringify(result)); - } -} - function createLink(rootValue?: Record) { return new ApolloLink((operation) => { - return from(run(operation.query, rootValue)); + return from( + executeSchemaGraphQL17Alpha9(schema, operation.query, rootValue) + ); }); } diff --git a/src/incremental/handlers/__tests__/defer20220824/defer.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts index 51f2eb9c874..5573423d24f 100644 --- a/src/incremental/handlers/__tests__/defer20220824/defer.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, @@ -16,6 +9,7 @@ import { GraphQLString, } from "graphql-17-alpha2"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -27,6 +21,7 @@ import { } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, mockDefer20220824, ObservableStream, @@ -105,30 +100,8 @@ 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) => { diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index 485c8a69b69..d560165c819 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -1,12 +1,6 @@ import assert from "node:assert"; -import type { - FormattedExecutionResult, - FormattedInitialIncrementalExecutionResult, - FormattedSubsequentIncrementalExecutionResult, -} from "graphql-17-alpha2"; import { - experimentalExecuteIncrementally, GraphQLID, GraphQLList, GraphQLNonNull, @@ -26,6 +20,7 @@ import { } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha2, markAsStreaming, ObservableStream, promiseWithResolvers, @@ -94,30 +89,8 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -async function* run( - 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)); - } +function run(document: DocumentNode, rootValue: unknown = {}) { + return executeSchemaGraphQL17Alpha2(schema, document, rootValue); } function createSchemaLink(rootValue?: Record) { diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts index 06f47d063bc..96b493852bd 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, @@ -16,6 +9,7 @@ import { GraphQLString, } from "graphql-17-alpha9"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, @@ -27,6 +21,7 @@ import { } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha9, markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, @@ -154,36 +149,17 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -async function* run( +function run( document: DocumentNode, - rootValue: Record = { hero }, - enableEarlyExecution = false -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - | FormattedExecutionResult, - void -> { - const result = await experimentalExecuteIncrementally({ + rootValue: unknown = {}, + 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) { diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index db3beac262b..9f2b40356ef 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.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, @@ -20,12 +13,14 @@ import { from } from "rxjs"; import { ApolloClient, ApolloLink, + DocumentNode, gql, InMemoryCache, NetworkStatus, } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { + executeSchemaGraphQL17Alpha9, markAsStreaming, ObservableStream, promiseWithResolvers, @@ -98,32 +93,17 @@ function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } -async function* run( +function run( document: DocumentNode, rootValue: unknown = {}, enableEarlyExecution = false -): AsyncGenerator< - | FormattedInitialIncrementalExecutionResult - | FormattedSubsequentIncrementalExecutionResult - | FormattedExecutionResult, - void -> { - const result = await experimentalExecuteIncrementally({ +) { + return executeSchemaGraphQL17Alpha9( 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)); - } + enableEarlyExecution + ); } function createSchemaLink(rootValue?: Record) { From 1d3f36c3a8a3480e585416538f4964bc6a0c2c9e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 14:36:43 -0600 Subject: [PATCH 55/97] Simplify link in tests --- .../handlers/__tests__/defer20220824/defer.test.ts | 11 ++--------- .../handlers/__tests__/graphql17Alpha9/defer.test.ts | 11 ++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/defer.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts index 5573423d24f..2d416198617 100644 --- a/src/incremental/handlers/__tests__/defer20220824/defer.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -8,6 +8,7 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha2"; +import { from } from "rxjs"; import type { DocumentNode } from "@apollo/client"; import { @@ -17,7 +18,6 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { Defer20220824Handler } from "@apollo/client/incremental"; import { @@ -105,14 +105,7 @@ function run(query: DocumentNode) { } 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", () => { diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts index 96b493852bd..1d40bc7fc0a 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -8,6 +8,7 @@ import { GraphQLSchema, GraphQLString, } from "graphql-17-alpha9"; +import { from } from "rxjs"; import type { DocumentNode } from "@apollo/client"; import { @@ -17,7 +18,6 @@ import { gql, InMemoryCache, NetworkStatus, - Observable, } from "@apollo/client"; import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { @@ -164,14 +164,7 @@ function run( 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)); }); } From 7d238ff3f63b6391d637a6767be18dd2795f736c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 14:55:37 -0600 Subject: [PATCH 56/97] Add schemas for the friend list --- src/testing/internal/index.ts | 3 + .../schemas/friendList.graphql17Alpha2.ts | 62 +++++++++++++++++++ .../schemas/friendList.graphql17Alpha9.ts | 62 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/testing/internal/schemas/friendList.graphql17Alpha2.ts create mode 100644 src/testing/internal/schemas/friendList.graphql17Alpha9.ts diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index f08e6b027bd..54301cd5c61 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -38,3 +38,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/schemas/friendList.graphql17Alpha2.ts b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts new file mode 100644 index 00000000000..07ab96da399 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts @@ -0,0 +1,62 @@ +import { + GraphQLID, + 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), + }, + 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..1cd844a5203 --- /dev/null +++ b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts @@ -0,0 +1,62 @@ +import { + GraphQLID, + 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), + }, + 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 }); From fc671ff0d0a9612cfffda8aa085ff142d6a27808 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 15:42:02 -0600 Subject: [PATCH 57/97] Add helper to emit values in async iterable --- src/testing/internal/asyncIterableSubject.ts | 16 ++++++++++++++++ src/testing/internal/index.ts | 1 + 2 files changed, 17 insertions(+) create mode 100644 src/testing/internal/asyncIterableSubject.ts 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/index.ts b/src/testing/internal/index.ts index 54301cd5c61..de686fc955d 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -23,6 +23,7 @@ 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"; From 1e3e80cf079ae34caf0df986be05a0287fcf3eae Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 17:52:50 -0600 Subject: [PATCH 58/97] Add offset arg to friendList --- src/testing/internal/schemas/friendList.graphql17Alpha9.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha9.ts b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts index 1cd844a5203..4f774afab13 100644 --- a/src/testing/internal/schemas/friendList.graphql17Alpha9.ts +++ b/src/testing/internal/schemas/friendList.graphql17Alpha9.ts @@ -1,5 +1,6 @@ import { GraphQLID, + GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -26,6 +27,11 @@ const query = new GraphQLObjectType({ }, friendList: { type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, }, nonNullFriendList: { type: new GraphQLList(new GraphQLNonNull(friendType)), From c22394bd706bc5c7543bd7eb03f921ec4cc6f1d9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 18:21:17 -0600 Subject: [PATCH 59/97] Add dom.asyncIterable to tests --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 38b24c7132f2f8b97383fd9683e0a7b0a431853c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 19:17:53 -0600 Subject: [PATCH 60/97] Add tests for useSuspenseQuery with @stream --- .../streamGraphQL17Alpha9.test.tsx | 1688 +++++++++++++++++ 1 file changed, 1688 insertions(+) create mode 100644 src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx 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..39e93447e45 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -0,0 +1,1688 @@ +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 { preventUnhandledRejection } from "@apollo/client/utilities/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +async function renderSuspenseHook< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useSuspenseQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "ErrorBoundary" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, takeRender, rerender }; +} + +function createLink(rootValue?: unknown) { + return new ApolloLink((operation) => { + return from( + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) + ); + }); +} + +const friends = [ + { name: "Luke", id: 1 }, + { name: "Han", id: 2 }, + { name: "Leia", id: 3 }, +]; + +type Friend = (typeof friends)[number]; + +test("suspends streamed queries until initial chunk loads then streams in data as it loads", async () => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + return stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.each([ + "cache-first", + "network-only", + "no-cache", + "cache-and-network", +])( + 'suspends streamed queries until initial chunk loads then streams in data as it loads when using a "%s" fetch policy', + async (fetchPolicy) => { + const { stream, subject } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + subject.next(friends[0]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[1]); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + ], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + subject.next(friends[2]); + subject.complete(); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: [ + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + } +); + +test('does not suspend streamed queries with data in the cache and using a "cache-first" fetch policy', async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + // Use a query without `@stream` to ensure it doesn't affect the cache + query: gql` + query { + friendList { + id + name + } + } + `, + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { + wrapper: createClientWrapper(client), + } + ); + + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + friendList: friends.map((friend) => ({ + __typename: "Friend", + ...friend, + })), + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + await expect(takeRender).not.toRerender(); +}); + +test.failing( + 'does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', + async () => { + const { subject, stream } = asyncIterableSubject(); + + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const cache = new InMemoryCache(); + + // 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[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: "Han" }, + { __typename: "Friend", id: "3" }, + ], + }), + 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: 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.failing( + '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: { + friends: [ + { __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.failing( + "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 result = asyncIterableSubject(); + subject = result.subject; + + return result.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 `@defer` but there is currently a bug in the core +// implementation that prevents updates until the final result is returned. +// This test reflects the behavior as it exists today, but will need +// to be updated once the core bug is fixed. +// +// NOTE: A duplicate it.failng test has been added right below this one with +// the expected behavior added in (i.e. the commented code in this test). Once +// the core bug is fixed, this test can be removed in favor of the other test. +// +// https://github.com/apollographql/apollo-client/issues/11034 +test.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.failing( + "adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", + async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + return friends.map((f, i) => { + if (i === 1) { + return preventUnhandledRejection( + Promise.reject(new Error("Could not get friend")) + ); + } + + return { + id: f.id, + name: wait(i * 50).then(() => f.name), + }; + }); + }, + }), + 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"]); + } + + { + 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] }, + ], + }), + }); + } + + { + 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: 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.failing( + "adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", + async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + return friends.map((f, i) => { + if (i === 1) { + return preventUnhandledRejection( + Promise.reject(new Error("Could not get friend")) + ); + } + + return { + id: f.id, + name: wait(i * 50).then(() => f.name), + }; + }); + }, + }), + 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"]); + } + + { + 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, + }); + } + + { + 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.failing( + "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 returnError = true; + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + return friends.map((f, i) => { + if (i === 1 && returnError) { + return preventUnhandledRejection( + Promise.reject(new Error("Could not get friend")) + ); + } + + return { + id: f.id, + name: wait(i * 50).then(() => f.name), + }; + }); + }, + }), + 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"]); + } + + { + 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] }, + ], + }), + }); + } + + { + 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.ready, + 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] }, + ], + }), + }); + } + + returnError = false; + const refetchPromise = getCurrentSnapshot().refetch(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + { + 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, + }); + } + + { + 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, + }); + } + + { + 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(); + } +); From 02bde88110101aff0567c571d3df65535b91c6f9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 19:21:35 -0600 Subject: [PATCH 61/97] Fix eslint issue --- .../handlers/__tests__/graphql17Alpha9/stream.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 9f2b40356ef..5170ce2d25c 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -10,10 +10,10 @@ import { } from "graphql-17-alpha9"; import { from } from "rxjs"; +import type { DocumentNode } from "@apollo/client"; import { ApolloClient, ApolloLink, - DocumentNode, gql, InMemoryCache, NetworkStatus, From ef6f5b821854497add5439ba79250a7f1b13873d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 21:22:49 -0600 Subject: [PATCH 62/97] WIP cache stream update --- .../__tests__/graphql17Alpha9/stream.test.ts | 214 ++++++++++++++++++ .../streamGraphQL17Alpha9.test.tsx | 24 +- 2 files changed, 226 insertions(+), 12 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 5170ce2d25c..a64b6086e31 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2409,3 +2409,217 @@ test("GraphQL17Alpha9Handler can be used with `ApolloClient`", async () => { 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" }, { 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); + } +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index 39e93447e45..2102aee2261 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -369,7 +369,7 @@ test('does not suspend streamed queries with data in the cache and using a "cach await expect(takeRender).not.toRerender(); }); -test.failing( +test.skip.failing( 'does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', async () => { const { subject, stream } = asyncIterableSubject(); @@ -500,7 +500,7 @@ test.failing( } ); -test.failing( +test.skip.failing( 'does not suspend streamed queries with data in the cache and using a "cache-and-network" fetch policy', async () => { const { stream, subject } = asyncIterableSubject(); @@ -600,7 +600,7 @@ test.failing( } ); -test.failing( +test.skip.failing( "incrementally rerenders data returned by a `refetch` for a streamed query", async () => { let subject!: Subject; @@ -738,7 +738,7 @@ test.failing( } ); -test("incrementally renders data returned after skipping a streamed query", async () => { +test.skip("incrementally renders data returned after skipping a streamed query", async () => { const { stream, subject } = asyncIterableSubject(); const query = gql` query { @@ -836,7 +836,7 @@ test("incrementally renders data returned after skipping a streamed query", asyn // 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( +test.skip.failing( "rerenders data returned by `fetchMore` for a streamed query", async () => { let subject!: Subject; @@ -992,7 +992,7 @@ test.failing( // is fixed. // // https://github.com/apollographql/apollo-client/issues/11034 -test.failing( +test.skip.failing( "incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { let subject!: Subject; @@ -1142,7 +1142,7 @@ test.failing( } ); -test("throws network errors returned by streamed queries", async () => { +test.skip("throws network errors returned by streamed queries", async () => { using _consoleSpy = spyOnConsole("error"); const query = gql` @@ -1186,7 +1186,7 @@ test("throws network errors returned by streamed queries", async () => { await expect(takeRender).not.toRerender(); }); -test("throws graphql errors returned by streamed queries", async () => { +test.skip("throws graphql errors returned by streamed queries", async () => { using _consoleSpy = spyOnConsole("error"); const query = gql` @@ -1238,7 +1238,7 @@ test("throws graphql errors returned by streamed queries", async () => { await expect(takeRender).not.toRerender(); }); -test("discards partial data and throws errors returned in incremental chunks", async () => { +test.skip("discards partial data and throws errors returned in incremental chunks", async () => { const { stream, subject } = asyncIterableSubject(); using _consoleSpy = spyOnConsole("error"); @@ -1314,7 +1314,7 @@ test("discards partial data and throws errors returned in incremental chunks", a await expect(takeRender).not.toRerender(); }); -test.failing( +test.skip.failing( "adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { const query = gql` @@ -1413,7 +1413,7 @@ test.failing( } ); -test.failing( +test.skip.failing( "adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { const query = gql` @@ -1494,7 +1494,7 @@ test.failing( } ); -test.failing( +test.skip.failing( "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 returnError = true; From 208ddd0317be6e7fb5e53e54b7e35b3c00a0304b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 22:27:12 -0600 Subject: [PATCH 63/97] Don't set this.data to cacheData and instead merge at the end --- src/incremental/handlers/graphql17Alpha9.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 58ba0b79cae..a9fac8090ed 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -82,6 +82,13 @@ class IncrementalRequest { hasNext = true; + // `this.data` represents the merged results of all raw chunk data without + // cache data mixed in. This makes it easier to track incremental @stream + // chunks since they can be concatenated with the results streamed directly + // from the server, rather than concatenated with a cache list that might + // already have a non-zero length. Cache data is deep merged with this.data at + // the end to ensure this.data overwrites array indexes from increemntal + // chunks at the right location. private data: any = {}; private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; @@ -92,7 +99,10 @@ class IncrementalRequest chunk: GraphQL17Alpha9Handler.Chunk ): FormattedExecutionResult { this.hasNext = chunk.hasNext; - this.data = cacheData; + + if ("data" in chunk) { + this.data = chunk.data; + } if (chunk.pending) { this.pending.push(...chunk.pending); @@ -146,7 +156,9 @@ class IncrementalRequest } } - const result: FormattedExecutionResult = { data: this.data }; + const result: FormattedExecutionResult = { + data: new DeepMerger().merge(cacheData, this.data), + }; if (isNonEmptyArray(this.errors)) { result.errors = this.errors; From 6e72cfa1e1835e535b47ee383ea2ba5e8a944d73 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 22:29:24 -0600 Subject: [PATCH 64/97] Extract helper to deep merge --- src/incremental/handlers/graphql17Alpha9.ts | 28 ++++++++++----------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index a9fac8090ed..21545801e93 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -108,7 +108,7 @@ class IncrementalRequest this.pending.push(...chunk.pending); } - this.merge(chunk, new DeepMerger()); + this.merge(chunk); if (hasIncrementalChunks(chunk)) { for (const incremental of chunk.incremental) { @@ -135,14 +135,11 @@ class IncrementalRequest data = parent as typeof data; } - this.merge( - { - data, - extensions: incremental.extensions, - errors: incremental.errors, - }, - new DeepMerger() - ); + this.merge({ + data, + extensions: incremental.extensions, + errors: incremental.errors, + }); } } @@ -157,7 +154,7 @@ class IncrementalRequest } const result: FormattedExecutionResult = { - data: new DeepMerger().merge(cacheData, this.data), + data: deepMerge(cacheData, this.data), }; if (isNonEmptyArray(this.errors)) { @@ -171,12 +168,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 = deepMerge(this.data, normalized.data); } if (normalized.errors) { @@ -187,6 +181,10 @@ class IncrementalRequest } } +function deepMerge(target: T, source: T): T { + return new DeepMerger().merge(target, source); +} + /** * Provides handling for the incremental delivery specification implemented by * graphql.js version `17.0.0-alpha.9`. From 9961449fc46ea6e12213fd7c8c55e530f1de5fbf Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 22:35:28 -0600 Subject: [PATCH 65/97] Add additional test cases --- .../__tests__/graphql17Alpha9/stream.test.ts | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index a64b6086e31..e3ae22def74 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2579,7 +2579,7 @@ test("properly merges streamed data into list with fewer items", async () => { expect( request.handle( { - friendList: [{ id: "1" }, { id: "2" }, { id: "3" }], + friendList: [{ id: "1", name: "Luke Cached" }], }, chunk ) @@ -2588,7 +2588,6 @@ test("properly merges streamed data into list with fewer items", async () => { friendList: [ { name: "Luke", id: "1" }, { name: "Han", id: "2" }, - { id: "3" }, ], }, }); @@ -2606,7 +2605,6 @@ test("properly merges streamed data into list with fewer items", async () => { friendList: [ { name: "Luke", id: "1" }, { name: "Han", id: "2" }, - { id: "3" }, ], }, chunk @@ -2623,3 +2621,81 @@ test("properly merges streamed data into list with fewer items", async () => { 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); + } +}); From b87639a6b31fb45c5dbf7498e0d556c9b2b14d57 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 22:49:25 -0600 Subject: [PATCH 66/97] Re-enable most tests --- .../streamGraphQL17Alpha9.test.tsx | 611 +++++++++--------- 1 file changed, 304 insertions(+), 307 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index 2102aee2261..f56beae502f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -369,158 +369,179 @@ test('does not suspend streamed queries with data in the cache and using a "cach await expect(takeRender).not.toRerender(); }); -test.skip.failing( - 'does not suspend streamed queries with partial data in the cache and using a "cache-first" fetch policy with `returnPartialData`', - async () => { - const { subject, stream } = asyncIterableSubject(); +// 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 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(), + 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), + })), + }, }); + } - using _disabledAct = disableActEnvironment(); - const { takeRender } = await renderSuspenseHook( - () => - useSuspenseQuery(query, { - fetchPolicy: "cache-first", - returnPartialData: true, - }), - { - wrapper: createClientWrapper(client), - } - ); + 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, + }), { - 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, - }); + wrapper: createClientWrapper(client), } + ); - subject.next(friends[0]); + { + const { snapshot, renderedComponents } = await takeRender(); - { - 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, + }); + } - 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[0]); - subject.next(friends[0]); + { + const { snapshot, renderedComponents } = await takeRender(); - { - 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, + }); + } - 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[1]); - subject.next(friends[1]); - subject.complete(); + { + const { snapshot, renderedComponents } = await takeRender(); - { - 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, + }); + } - 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, - }); - } + subject.next(friends[2]); + subject.complete(); - await expect(takeRender).not.toRerender(); + { + 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, + }); } -); -test.skip.failing( - '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 - } + 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(), - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ friendList: () => stream }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); - client.writeQuery({ - query, + 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" }, @@ -528,205 +549,168 @@ test.skip.failing( { __typename: "Friend", id: "3", name: "Cached Leia" }, ], }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, }); + } - 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(); + subject.next(friends[0]); - 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, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - subject.next(friends[1]); - subject.next(friends[2]); - subject.complete(); + 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, + }); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: { - friends: [ - { __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 { snapshot, renderedComponents } = await takeRender(); - await expect(takeRender).not.toRerender(); + 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, + }); } -); -test.skip.failing( - "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 - } - } - `; + await expect(takeRender).not.toRerender(); +}); - const client = new ApolloClient({ - link: createLink({ - friendList: () => { - const result = asyncIterableSubject(); - subject = result.subject; +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 + } + } + `; - return result.stream; - }, - }), - cache: new InMemoryCache(), - incrementalHandler: new GraphQL17Alpha9Handler(), - }); + const client = new ApolloClient({ + link: createLink({ + friendList: () => { + const iterable = asyncIterableSubject(); + subject = iterable.subject; - using _disabledAct = disableActEnvironment(); - const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( - () => useSuspenseQuery(query), - { wrapper: createClientWrapper(client) } - ); + return iterable.stream; + }, + }), + cache: new InMemoryCache(), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); - { - const { renderedComponents } = await takeRender(); + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + { + const { renderedComponents } = await takeRender(); - subject.next(friends[0]); + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next(friends[0]); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - subject.next(friends[1]); - subject.next(friends[2]); - subject.complete(); + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next(friends[1]); + subject.next(friends[2]); + subject.complete(); - 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 { snapshot, renderedComponents } = await takeRender(); - const refetchPromise = getCurrentSnapshot().refetch(); + 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 { renderedComponents } = await takeRender(); + const refetchPromise = getCurrentSnapshot().refetch(); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + { + const { renderedComponents } = await takeRender(); - subject.next({ id: 1, name: "Luke (refetch)" }); + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next({ id: 1, name: "Luke (refetch)" }); - 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, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - subject.next({ id: 2, name: "Han (refetch)" }); - subject.next({ id: 3, name: "Leia (refetch)" }); - subject.complete(); + 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, + }); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next({ id: 2, name: "Han (refetch)" }); + subject.next({ id: 3, name: "Leia (refetch)" }); + subject.complete(); - 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, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - await expect(refetchPromise).resolves.toStrictEqualTyped({ + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { friendList: [ { __typename: "Friend", id: "1", name: "Luke (refetch)" }, @@ -734,11 +718,24 @@ test.skip.failing( { __typename: "Friend", id: "3", name: "Leia (refetch)" }, ], }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, }); } -); -test.skip("incrementally renders data returned after skipping a streamed query", async () => { + 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 { @@ -836,7 +833,7 @@ test.skip("incrementally renders data returned after skipping a streamed query", // 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.skip.failing( +test.failing( "rerenders data returned by `fetchMore` for a streamed query", async () => { let subject!: Subject; @@ -992,7 +989,7 @@ test.skip.failing( // is fixed. // // https://github.com/apollographql/apollo-client/issues/11034 -test.skip.failing( +test.failing( "incrementally rerenders data returned by a `fetchMore` for a streamed query", async () => { let subject!: Subject; @@ -1142,7 +1139,7 @@ test.skip.failing( } ); -test.skip("throws network errors returned by streamed queries", async () => { +test("throws network errors returned by streamed queries", async () => { using _consoleSpy = spyOnConsole("error"); const query = gql` @@ -1186,7 +1183,7 @@ test.skip("throws network errors returned by streamed queries", async () => { await expect(takeRender).not.toRerender(); }); -test.skip("throws graphql errors returned by streamed queries", async () => { +test("throws graphql errors returned by streamed queries", async () => { using _consoleSpy = spyOnConsole("error"); const query = gql` @@ -1238,7 +1235,7 @@ test.skip("throws graphql errors returned by streamed queries", async () => { await expect(takeRender).not.toRerender(); }); -test.skip("discards partial data and throws errors returned in incremental chunks", async () => { +test("discards partial data and throws errors returned in incremental chunks", async () => { const { stream, subject } = asyncIterableSubject(); using _consoleSpy = spyOnConsole("error"); @@ -1314,7 +1311,7 @@ test.skip("discards partial data and throws errors returned in incremental chunk await expect(takeRender).not.toRerender(); }); -test.skip.failing( +test.failing( "adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { const query = gql` @@ -1413,7 +1410,7 @@ test.skip.failing( } ); -test.skip.failing( +test.failing( "adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { const query = gql` @@ -1494,7 +1491,7 @@ test.skip.failing( } ); -test.skip.failing( +test.failing( "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 returnError = true; From 1addfc101f34bfa3f519ec23284ea06913c9f0ea Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:06:10 -0600 Subject: [PATCH 67/97] Maintain a queue of the last delivery in case there are no listeners --- src/react/internal/cache/QueryReference.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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)); } From 172da1cd192187369f2a6a08c179e7a3d19ec0a2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:07:41 -0600 Subject: [PATCH 68/97] Enable some failing tests --- .../streamGraphQL17Alpha9.test.tsx | 286 +++++++++--------- 1 file changed, 138 insertions(+), 148 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index f56beae502f..7a4911482a7 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -1311,77 +1311,83 @@ test("discards partial data and throws errors returned in incremental chunks", a await expect(takeRender).not.toRerender(); }); -test.failing( - "adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", - async () => { - const query = gql` - query { - friendList @stream(initialCount: 1) { - id - name - } +test("adds partial data and does not throw errors returned in incremental chunks but returns them in `error` property with errorPolicy set to `all`", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name } - `; + } + `; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: createLink({ - friendList: () => { - return friends.map((f, i) => { - if (i === 1) { - return preventUnhandledRejection( - Promise.reject(new Error("Could not get friend")) - ); - } + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + return friends.map((f, i) => { + if (i === 1) { + return preventUnhandledRejection( + Promise.reject(new Error("Could not get friend")) + ); + } - return { - id: f.id, - name: wait(i * 50).then(() => f.name), - }; - }); - }, - }), - incrementalHandler: new GraphQL17Alpha9Handler(), - }); + return { + id: f.id, + name: wait(i * 50).then(() => f.name), + }; + }); + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); - using _disabledAct = disableActEnvironment(); - const { takeRender } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { wrapper: createClientWrapper(client) } - ); + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); - { - const { renderedComponents } = await takeRender(); + { + const { renderedComponents } = await takeRender(); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - { - const { snapshot, renderedComponents } = await takeRender(); + { + const { snapshot, renderedComponents } = await takeRender(); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ + 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], - }), - 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] }, - ], - }), - }); - } + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } - { - const { snapshot, renderedComponents } = await takeRender(); + { + const { snapshot, renderedComponents } = await takeRender(); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ + 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" }, @@ -1389,107 +1395,91 @@ test.failing( { __typename: "Friend", id: "3", name: "Leia" }, ], }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - 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(); + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); } -); -test.failing( - "adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", - async () => { - const query = gql` - query { - friendList @stream(initialCount: 1) { - id - name - } - } - `; + await expect(takeRender).not.toRerender(); +}); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: createLink({ - friendList: () => { - return friends.map((f, i) => { - if (i === 1) { - return preventUnhandledRejection( - Promise.reject(new Error("Could not get friend")) - ); - } +test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { + const query = gql` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `; - return { - id: f.id, - name: wait(i * 50).then(() => f.name), - }; - }); - }, - }), - incrementalHandler: new GraphQL17Alpha9Handler(), - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: () => { + return friends.map((f, i) => { + if (i === 1) { + return preventUnhandledRejection( + Promise.reject(new Error("Could not get friend")) + ); + } - using _disabledAct = disableActEnvironment(); - const { takeRender } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "ignore" }), - { wrapper: createClientWrapper(client) } - ); + return { + id: f.id, + name: wait(i * 50).then(() => f.name), + }; + }); + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); - { - const { renderedComponents } = await takeRender(); + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + { + const { renderedComponents } = await takeRender(); - { - const { snapshot, renderedComponents } = await takeRender(); + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - { - 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, + }); + } - 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, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - await expect(takeRender).not.toRerender(); + 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.failing( "can refetch and respond to cache updates after encountering an error in an incremental chunk for a streamed query when `errorPolicy` is `all`", From f58d7241efd2c901a035d40a3149f79502eb588c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:20:59 -0600 Subject: [PATCH 69/97] Use subject to control last test --- .../streamGraphQL17Alpha9.test.tsx | 306 +++++++++--------- 1 file changed, 154 insertions(+), 152 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index 7a4911482a7..a6524b0384d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -1481,161 +1481,150 @@ test("adds partial data and discards errors returned in incremental chunks with await expect(takeRender).not.toRerender(); }); -test.failing( - "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 returnError = true; - const query = gql` - query { - friendList @stream(initialCount: 1) { - id - name - } +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: () => { - return friends.map((f, i) => { - if (i === 1 && returnError) { - return preventUnhandledRejection( - Promise.reject(new Error("Could not get friend")) - ); - } - - return { - id: f.id, - name: wait(i * 50).then(() => f.name), - }; - }); - }, - }), - incrementalHandler: new GraphQL17Alpha9Handler(), - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: createLink({ + friendList: async () => { + const iterable = asyncIterableSubject | Friend>(); + subject = iterable.subject; - using _disabledAct = disableActEnvironment(); - const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( - () => useSuspenseQuery(query, { errorPolicy: "all" }), - { wrapper: createClientWrapper(client) } - ); + return iterable.stream; + }, + }), + incrementalHandler: new GraphQL17Alpha9Handler(), + }); - { - const { renderedComponents } = await takeRender(); + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot } = await renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + { + const { renderedComponents } = await takeRender(); - { - const { snapshot, renderedComponents } = await takeRender(); + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ + subject.next(friends[0]); + 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], - }), - 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] }, - ], - }), - }); - } + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next(friends[2]); + subject.complete(); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ + { + 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" }, ], - }), - dataState: "complete", - networkStatus: NetworkStatus.ready, - 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] }, - ], - }), - }); - } + }, + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], + }), + }); + } - returnError = false; - const refetchPromise = getCurrentSnapshot().refetch(); + const refetchPromise = getCurrentSnapshot().refetch(); - { - const { renderedComponents } = await takeRender(); + { + const { renderedComponents } = await takeRender(); - expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); - } + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } - { - const { snapshot, renderedComponents } = await takeRender(); + subject.next(friends[0]); - expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); - expect(snapshot).toStrictEqualTyped({ - data: markAsStreaming({ - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], - }), - dataState: "streaming", - networkStatus: NetworkStatus.streaming, - error: undefined, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - { - 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, + }); + } - 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[1]); - { - const { snapshot, renderedComponents } = await takeRender(); + { + 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, - }); - } + 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(); - await expect(refetchPromise).resolves.toStrictEqualTyped({ + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { friendList: [ { __typename: "Friend", id: "1", name: "Luke" }, @@ -1643,33 +1632,46 @@ test.failing( { __typename: "Friend", id: "3", name: "Leia" }, ], }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, }); + } - client.cache.updateQuery({ query }, (data) => ({ + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { friendList: [ - { ...data.friendList[0], name: "Luke (updated)" }, - ...data.friendList.slice(1), + { __typename: "Friend", id: "1", name: "Luke" }, + { __typename: "Friend", id: "2", name: "Han" }, + { __typename: "Friend", id: "3", name: "Leia" }, ], - })); + }, + }); - { - const { snapshot, renderedComponents } = await takeRender(); + client.cache.updateQuery({ query }, (data) => ({ + friendList: [ + { ...data.friendList[0], name: "Luke (updated)" }, + ...data.friendList.slice(1), + ], + })); - 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, - }); - } + { + const { snapshot, renderedComponents } = await takeRender(); - await expect(takeRender).not.toRerender(); + 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(); +}); From 52aff0428714dd243f6a37db33bc7d2b2e1f390e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:24:04 -0600 Subject: [PATCH 70/97] Use test helpers --- .../streamGraphQL17Alpha9.test.tsx | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index a6524b0384d..dd3aafc5d4b 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -1312,6 +1312,7 @@ test("discards partial data and throws errors returned in incremental chunks", a }); 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) { @@ -1323,22 +1324,7 @@ test("adds partial data and does not throw errors returned in incremental chunks const client = new ApolloClient({ cache: new InMemoryCache(), - link: createLink({ - friendList: () => { - return friends.map((f, i) => { - if (i === 1) { - return preventUnhandledRejection( - Promise.reject(new Error("Could not get friend")) - ); - } - - return { - id: f.id, - name: wait(i * 50).then(() => f.name), - }; - }); - }, - }), + link: createLink({ friendList: () => stream }), incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -1354,6 +1340,9 @@ test("adds partial data and does not throw errors returned in incremental chunks expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); } + subject.next(friends[0]); + subject.next(Promise.reject(new Error("Could not get friend"))); + { const { snapshot, renderedComponents } = await takeRender(); @@ -1373,6 +1362,9 @@ test("adds partial data and does not throw errors returned in incremental chunks }); } + subject.next(friends[2]); + subject.complete(); + { const { snapshot, renderedComponents } = await takeRender(); @@ -1404,6 +1396,7 @@ test("adds partial data and does not throw errors returned in incremental chunks }); 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) { @@ -1416,20 +1409,7 @@ test("adds partial data and discards errors returned in incremental chunks with const client = new ApolloClient({ cache: new InMemoryCache(), link: createLink({ - friendList: () => { - return friends.map((f, i) => { - if (i === 1) { - return preventUnhandledRejection( - Promise.reject(new Error("Could not get friend")) - ); - } - - return { - id: f.id, - name: wait(i * 50).then(() => f.name), - }; - }); - }, + friendList: () => stream, }), incrementalHandler: new GraphQL17Alpha9Handler(), }); @@ -1446,6 +1426,9 @@ test("adds partial data and discards errors returned in incremental chunks with expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); } + subject.next(friends[0]); + subject.next(Promise.reject(new Error("Could not get friend"))); + { const { snapshot, renderedComponents } = await takeRender(); @@ -1460,6 +1443,9 @@ test("adds partial data and discards errors returned in incremental chunks with }); } + subject.next(friends[2]); + subject.complete(); + { const { snapshot, renderedComponents } = await takeRender(); From d4dca15da8ca650b6499926ab9664eb1f1572f2c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:34:27 -0600 Subject: [PATCH 71/97] Fix comment --- .../__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index dd3aafc5d4b..bfb3e9fcdb6 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -823,7 +823,7 @@ test("incrementally renders data returned after skipping a streamed query", asyn }); // TODO: This test is a bit of a lie. `fetchMore` should incrementally -// rerender when using `@defer` but there is currently a bug in the core +// 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. From 65d04e5efa3c8d5f8e4da97e33425f5ffd408413 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:34:47 -0600 Subject: [PATCH 72/97] Remove unused import --- .../__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index bfb3e9fcdb6..cba3e80d7fa 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -30,7 +30,6 @@ import { wait, } from "@apollo/client/testing/internal"; import { offsetLimitPagination } from "@apollo/client/utilities"; -import { preventUnhandledRejection } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; async function renderSuspenseHook< From 8a62e8a19b64e98583cea17a864456b51ab37f9b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:39:45 -0600 Subject: [PATCH 73/97] Change expect to assert to fix ts error --- .../__tests__/defer20220824/defer.test.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/incremental/handlers/__tests__/defer20220824/defer.test.ts b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts index 2d416198617..7ed32d8c991 100644 --- a/src/incremental/handlers/__tests__/defer20220824/defer.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/defer.test.ts @@ -132,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: { @@ -146,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: { @@ -200,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: {}, @@ -210,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: { @@ -241,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: {}, @@ -251,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: { @@ -295,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: { @@ -310,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: { @@ -346,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: { @@ -359,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: { @@ -399,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" } }, @@ -409,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: { @@ -448,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" } }, @@ -458,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: { @@ -529,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" } }, @@ -539,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: { @@ -555,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: { From a439d54a8f19951c32fd9d9facfc5ed70845202b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Sep 2025 23:40:24 -0600 Subject: [PATCH 74/97] Update exports snapshot --- src/__tests__/__snapshots__/exports.ts.snap | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index f043e84aad4..2dd66642204 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -357,11 +357,16 @@ Array [ "ObservableStream", "actAsync", "addDelayToMocks", + "asyncIterableSubject", "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", "enableFakeTimers", + "executeSchemaGraphQL17Alpha2", + "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", + "friendListSchemaGraphQL17Alpha2", + "friendListSchemaGraphQL17Alpha9", "markAsStreaming", "mockDefer20220824", "mockDeferStreamGraphQL17Alpha9", From ad7a87e8a70feb084615a1e5d009fdb15e0be079 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 09:59:48 -0600 Subject: [PATCH 75/97] Copy useSuspenseQuery stream tests for older spec --- .../streamDefer20220824.test.tsx | 1662 +++++++++++++++++ 1 file changed, 1662 insertions(+) create mode 100644 src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx 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..88f448a4837 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -0,0 +1,1662 @@ +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" }], + }, + 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 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]); + 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: { + 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]); + 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 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]); + 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(); +}); From 27bc2191732f8dfae9e09c4e4d46d71d99b07b9e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 11:03:31 -0600 Subject: [PATCH 76/97] Update details for older spec --- .../streamDefer20220824.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx index 88f448a4837..67c3f15d54c 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -1300,9 +1300,9 @@ test("discards partial data and throws errors returned in incremental chunks", a expect(snapshot).toStrictEqualTyped({ error: new CombinedGraphQLErrors({ data: { - friendList: [{ __typename: "Friend", id: "1", name: "Luke" }], + friendList: [{ __typename: "Friend", id: "1", name: "Luke" }, null], }, - errors: [{ message: "Could not get friend", path: ["friendList"] }], + errors: [{ message: "Could not get friend", path: ["friendList", 1] }], }), }); } @@ -1340,7 +1340,7 @@ test("adds partial data and does not throw errors returned in incremental chunks } subject.next(friends[0]); - subject.next(Promise.reject(new Error("Could not get friend"))); + subject.next(new Error("Could not get friend")); { const { snapshot, renderedComponents } = await takeRender(); @@ -1395,7 +1395,7 @@ test("adds partial data and does not throw errors returned in incremental chunks }); test("adds partial data and discards errors returned in incremental chunks with errorPolicy set to `ignore`", async () => { - const { stream, subject } = asyncIterableSubject>(); + const { stream, subject } = asyncIterableSubject(); const query = gql` query { friendList @stream(initialCount: 1) { @@ -1426,7 +1426,7 @@ test("adds partial data and discards errors returned in incremental chunks with } subject.next(friends[0]); - subject.next(Promise.reject(new Error("Could not get friend"))); + subject.next(new Error("Could not get friend")); { const { snapshot, renderedComponents } = await takeRender(); @@ -1467,7 +1467,7 @@ test("adds partial data and discards errors returned in incremental chunks with }); 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>; + let subject!: Subject; const query = gql` query { friendList @stream(initialCount: 1) { @@ -1480,8 +1480,8 @@ test("can refetch and respond to cache updates after encountering an error in an const client = new ApolloClient({ cache: new InMemoryCache(), link: createLink({ - friendList: async () => { - const iterable = asyncIterableSubject | Friend>(); + friendList: () => { + const iterable = asyncIterableSubject(); subject = iterable.subject; return iterable.stream; @@ -1503,7 +1503,7 @@ test("can refetch and respond to cache updates after encountering an error in an } subject.next(friends[0]); - subject.next(Promise.reject(new Error("Could not get friend"))); + subject.next(new Error("Could not get friend")); { const { snapshot, renderedComponents } = await takeRender(); From 77eb9c5cacb060bd1e4ede1bbe237d49a72da5ab Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 11:11:49 -0600 Subject: [PATCH 77/97] Revert to older implementation --- src/incremental/handlers/graphql17Alpha9.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 21545801e93..d17b73a578d 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -82,13 +82,6 @@ class IncrementalRequest { hasNext = true; - // `this.data` represents the merged results of all raw chunk data without - // cache data mixed in. This makes it easier to track incremental @stream - // chunks since they can be concatenated with the results streamed directly - // from the server, rather than concatenated with a cache list that might - // already have a non-zero length. Cache data is deep merged with this.data at - // the end to ensure this.data overwrites array indexes from increemntal - // chunks at the right location. private data: any = {}; private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; @@ -99,10 +92,7 @@ class IncrementalRequest chunk: GraphQL17Alpha9Handler.Chunk ): FormattedExecutionResult { this.hasNext = chunk.hasNext; - - if ("data" in chunk) { - this.data = chunk.data; - } + this.data = cacheData; if (chunk.pending) { this.pending.push(...chunk.pending); @@ -153,9 +143,7 @@ class IncrementalRequest } } - const result: FormattedExecutionResult = { - data: deepMerge(cacheData, this.data), - }; + const result: FormattedExecutionResult = { data: this.data }; if (isNonEmptyArray(this.errors)) { result.errors = this.errors; From fbb2f38557026e7bb0e0298a0534a2727011484b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 11:11:59 -0600 Subject: [PATCH 78/97] Inline deepMerge --- src/incremental/handlers/graphql17Alpha9.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index d17b73a578d..d9652bccca1 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -158,7 +158,7 @@ class IncrementalRequest private merge(normalized: FormattedExecutionResult) { if (normalized.data !== undefined) { - this.data = deepMerge(this.data, normalized.data); + this.data = new DeepMerger().merge(this.data, normalized.data); } if (normalized.errors) { @@ -169,10 +169,6 @@ class IncrementalRequest } } -function deepMerge(target: T, source: T): T { - return new DeepMerger().merge(target, source); -} - /** * Provides handling for the incremental delivery specification implemented by * graphql.js version `17.0.0-alpha.9`. From 198279c585f900b27232a363853bbaa4c86ff972 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 11:17:11 -0600 Subject: [PATCH 79/97] Fix missing default from change to shared function --- .../handlers/__tests__/graphql17Alpha9/defer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts index 1d40bc7fc0a..885a428f267 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/defer.test.ts @@ -151,7 +151,7 @@ function resolveOnNextTick(): Promise { function run( document: DocumentNode, - rootValue: unknown = {}, + rootValue: unknown = { hero }, enableEarlyExecution?: boolean ) { return executeSchemaGraphQL17Alpha9( From 356bfe78b8e6f3ebe3f800012acb5eb2035924dc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 12:53:15 -0600 Subject: [PATCH 80/97] Fix most cases of merging cache with streamed chunks --- src/incremental/handlers/graphql17Alpha9.ts | 35 +++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index d9652bccca1..978e12eec51 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -86,6 +86,7 @@ class IncrementalRequest private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; private pending: GraphQL17Alpha9Handler.PendingResult[] = []; + private mergedIndexes: Record = {}; handle( cacheData: TData | DeepPartial | null | undefined = this.data, @@ -96,6 +97,19 @@ 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.mergedIndexes[pending.id] = dataAtPath.length; + } + } + } } this.merge(chunk); @@ -110,12 +124,21 @@ class IncrementalRequest ); const path = pending.path.concat(incremental.subPath ?? []); - let data = - "items" in incremental ? - path - .reduce((data, key) => data[key], this.data) - .concat(incremental.items) - : incremental.data; + + let data: any; + if ("items" in incremental) { + const items = incremental.items as any[]; + const parent: any[] = []; + + for (let i = 0!; i < items.length; i++) { + parent[i + this.mergedIndexes[pending.id]] = items[i]; + } + + this.mergedIndexes[pending.id] += items.length; + data = parent; + } else { + data = incremental.data; + } for (let i = path.length - 1; i >= 0; i--) { const key = path[i]; From 1fdd487044cc9fd52b84838443026f58a0696557 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 13:07:57 -0600 Subject: [PATCH 81/97] Fix issue with non-zero lists sent with defer chunk --- src/incremental/handlers/graphql17Alpha9.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 978e12eec51..6dbe577a048 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -130,6 +130,15 @@ class IncrementalRequest const items = incremental.items as any[]; const parent: any[] = []; + if (!(pending.id in this.mergedIndexes)) { + const dataAtPath = pending.path.reduce( + (data, key) => (data as any)[key], + this.data + ); + + this.mergedIndexes[pending.id] = dataAtPath.length; + } + for (let i = 0!; i < items.length; i++) { parent[i + this.mergedIndexes[pending.id]] = items[i]; } From d366dc1719db040e2af80879ea9fd700ed426887 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 13:08:40 -0600 Subject: [PATCH 82/97] Add additional test to check non-zero length array in defer chunk --- .../__tests__/graphql17Alpha9/stream.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index e3ae22def74..f535cba6ace 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2127,6 +2127,103 @@ describe("graphql-js test cases", () => { } }); + // 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(); From 853b2d7734536653475db6654db72d8648731ba5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 13:22:29 -0600 Subject: [PATCH 83/97] Add failing test for merging cache data on defer chunk --- .../__tests__/graphql17Alpha9/stream.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index f535cba6ace..6ad0b17c939 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2796,3 +2796,157 @@ test("properly merges streamed data into list with more items", async () => { expect(request.hasNext).toBe(false); } }); + +it("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); + } +}); From 90c7e46a2666835e383d46ef72119ef3a057d269 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:06:53 -0600 Subject: [PATCH 84/97] Fix the failing test --- src/incremental/handlers/graphql17Alpha9.ts | 38 +++++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 6dbe577a048..a38323d88c6 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -130,16 +130,10 @@ class IncrementalRequest const items = incremental.items as any[]; const parent: any[] = []; - if (!(pending.id in this.mergedIndexes)) { - const dataAtPath = pending.path.reduce( - (data, key) => (data as any)[key], - this.data - ); - - this.mergedIndexes[pending.id] = dataAtPath.length; - } - - for (let i = 0!; i < items.length; i++) { + // 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.mergedIndexes[pending.id]] = items[i]; } @@ -147,6 +141,28 @@ class IncrementalRequest data = parent; } else { data = incremental.data; + + // For deferred data, check if any pending streams have data here + // and update mergedIndexes accordingly + // Look through all pending items to see if any have arrays in this incremental data + for (const pendingItem of this.pending) { + if (!(pendingItem.id in this.mergedIndexes)) { + // 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.mergedIndexes[pendingItem.id] = dataAtPath.length; + } + } + } } for (let i = path.length - 1; i >= 0; i--) { @@ -154,7 +170,7 @@ class IncrementalRequest const parent: Record = typeof key === "number" ? [] : {}; parent[key] = data; - data = parent as typeof data; + data = parent; } this.merge({ From b5581e1b54a277bf261736b97024f4eb5ff928b9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:08:08 -0600 Subject: [PATCH 85/97] Rename property --- src/incremental/handlers/graphql17Alpha9.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index a38323d88c6..33e943e4a52 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -86,7 +86,7 @@ class IncrementalRequest private errors: GraphQLFormattedError[] = []; private extensions: Record = {}; private pending: GraphQL17Alpha9Handler.PendingResult[] = []; - private mergedIndexes: Record = {}; + private streamPositions: Record = {}; handle( cacheData: TData | DeepPartial | null | undefined = this.data, @@ -106,7 +106,7 @@ class IncrementalRequest ); if (Array.isArray(dataAtPath)) { - this.mergedIndexes[pending.id] = dataAtPath.length; + this.streamPositions[pending.id] = dataAtPath.length; } } } @@ -134,10 +134,10 @@ class IncrementalRequest // 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.mergedIndexes[pending.id]] = items[i]; + parent[i + this.streamPositions[pending.id]] = items[i]; } - this.mergedIndexes[pending.id] += items.length; + this.streamPositions[pending.id] += items.length; data = parent; } else { data = incremental.data; @@ -146,7 +146,7 @@ class IncrementalRequest // and update mergedIndexes accordingly // Look through all pending items to see if any have arrays in this incremental data for (const pendingItem of this.pending) { - if (!(pendingItem.id in this.mergedIndexes)) { + 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"] @@ -159,7 +159,7 @@ class IncrementalRequest ); if (Array.isArray(dataAtPath)) { - this.mergedIndexes[pendingItem.id] = dataAtPath.length; + this.streamPositions[pendingItem.id] = dataAtPath.length; } } } From ae31314440a8a23d6469b8fc811ad6c16224adf3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:15:07 -0600 Subject: [PATCH 86/97] Add comment --- src/incremental/handlers/graphql17Alpha9.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index 33e943e4a52..e7fce195d4b 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -86,6 +86,13 @@ 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( From e67eab2d283a5fe45b54c0d709e28cd9dd79a952 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:27:23 -0600 Subject: [PATCH 87/97] Update comment --- src/incremental/handlers/graphql17Alpha9.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/incremental/handlers/graphql17Alpha9.ts b/src/incremental/handlers/graphql17Alpha9.ts index e7fce195d4b..2355ba10b05 100644 --- a/src/incremental/handlers/graphql17Alpha9.ts +++ b/src/incremental/handlers/graphql17Alpha9.ts @@ -149,9 +149,10 @@ class IncrementalRequest } else { data = incremental.data; - // For deferred data, check if any pending streams have data here - // and update mergedIndexes accordingly - // Look through all pending items to see if any have arrays in this 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 From 12a89fc3f4cd1e2d1f0ff5ee8014a85eebfdbcff Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:36:45 -0600 Subject: [PATCH 88/97] it -> test --- .../handlers/__tests__/graphql17Alpha9/stream.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index 6ad0b17c939..ac26a6505f0 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -2797,7 +2797,7 @@ test("properly merges streamed data into list with more items", async () => { } }); -it("properly merges cache data when list is included in deferred chunk", async () => { +test("properly merges cache data when list is included in deferred chunk", async () => { const { promise: slowFieldPromise, resolve: resolveSlowField } = promiseWithResolvers(); From d6198aef191bb6ae183f195f816c85f2b54f253f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:40:41 -0600 Subject: [PATCH 89/97] Use shared schema --- .../streamDefer20220824.test.ts | 69 ++----------------- .../streamGraphQL17Alpha9.test.ts | 69 ++----------------- .../__tests__/defer20220824/stream.test.ts | 69 ++----------------- .../__tests__/graphql17Alpha9/stream.test.ts | 65 +---------------- 4 files changed, 20 insertions(+), 252 deletions(-) diff --git a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts index 5c842c3db2b..01ab8f1f78a 100644 --- a/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts +++ b/src/core/__tests__/client.watchQuery/streamDefer20220824.test.ts @@ -1,11 +1,3 @@ -import { - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, -} from "graphql-17-alpha2"; import { from } from "rxjs"; import { @@ -19,76 +11,27 @@ import { import { Defer20220824Handler } from "@apollo/client/incremental"; import { executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, markAsStreaming, mockDefer20220824, ObservableStream, promiseWithResolvers, } from "@apollo/client/testing/internal"; -const friendType = new GraphQLObjectType({ - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - nonNullName: { type: new GraphQLNonNull(GraphQLString) }, - }, - name: "Friend", -}); - const friends = [ { name: "Luke", id: 1 }, { name: "Han", id: 2 }, { name: "Leia", id: 3 }, ]; -const query = new GraphQLObjectType({ - fields: { - scalarList: { - type: new GraphQLList(GraphQLString), - }, - scalarListList: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - friendList: { - type: new GraphQLList(friendType), - }, - 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", -}); - -const schema = new GraphQLSchema({ query }); - function createLink(rootValue?: Record) { return new ApolloLink((operation) => { return from( - executeSchemaGraphQL17Alpha2(schema, operation.query, rootValue) + executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + operation.query, + rootValue + ) ); }); } diff --git a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts index c41861e597e..bdcd108a54e 100644 --- a/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts +++ b/src/core/__tests__/client.watchQuery/streamGraphQL17Alpha9.test.ts @@ -1,11 +1,3 @@ -import { - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, -} from "graphql-17-alpha9"; import { from } from "rxjs"; import { @@ -19,76 +11,27 @@ import { import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, markAsStreaming, mockDeferStreamGraphQL17Alpha9, ObservableStream, promiseWithResolvers, } from "@apollo/client/testing/internal"; -const friendType = new GraphQLObjectType({ - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - nonNullName: { type: new GraphQLNonNull(GraphQLString) }, - }, - name: "Friend", -}); - const friends = [ { name: "Luke", id: 1 }, { name: "Han", id: 2 }, { name: "Leia", id: 3 }, ]; -const query = new GraphQLObjectType({ - fields: { - scalarList: { - type: new GraphQLList(GraphQLString), - }, - scalarListList: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - friendList: { - type: new GraphQLList(friendType), - }, - 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", -}); - -const schema = new GraphQLSchema({ query }); - function createLink(rootValue?: Record) { return new ApolloLink((operation) => { return from( - executeSchemaGraphQL17Alpha9(schema, operation.query, rootValue) + executeSchemaGraphQL17Alpha9( + friendListSchemaGraphQL17Alpha9, + operation.query, + rootValue + ) ); }); } diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index d560165c819..3dd497d67d1 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -1,13 +1,5 @@ import assert from "node:assert"; -import { - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, -} from "graphql-17-alpha2"; import { from } from "rxjs"; import type { DocumentNode } from "@apollo/client"; @@ -21,6 +13,7 @@ import { import { Defer20220824Handler } from "@apollo/client/incremental"; import { executeSchemaGraphQL17Alpha2, + friendListSchemaGraphQL17Alpha2, markAsStreaming, ObservableStream, promiseWithResolvers, @@ -29,68 +22,18 @@ import { // 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 friendType = new GraphQLObjectType({ - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - nonNullName: { type: new GraphQLNonNull(GraphQLString) }, - }, - name: "Friend", -}); - const friends = [ { name: "Luke", id: 1 }, { name: "Han", id: 2 }, { name: "Leia", id: 3 }, ]; -const query = new GraphQLObjectType({ - fields: { - scalarList: { - type: new GraphQLList(GraphQLString), - }, - scalarListList: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - friendList: { - type: new GraphQLList(friendType), - }, - 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", -}); - -const schema = new GraphQLSchema({ query }); - function run(document: DocumentNode, rootValue: unknown = {}) { - return executeSchemaGraphQL17Alpha2(schema, document, rootValue); + return executeSchemaGraphQL17Alpha2( + friendListSchemaGraphQL17Alpha2, + document, + rootValue + ); } function createSchemaLink(rootValue?: Record) { diff --git a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts index ac26a6505f0..7b00b258c9f 100644 --- a/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts +++ b/src/incremental/handlers/__tests__/graphql17Alpha9/stream.test.ts @@ -1,13 +1,5 @@ import assert from "node:assert"; -import { - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, -} from "graphql-17-alpha9"; import { from } from "rxjs"; import type { DocumentNode } from "@apollo/client"; @@ -21,6 +13,7 @@ import { import { GraphQL17Alpha9Handler } from "@apollo/client/incremental"; import { executeSchemaGraphQL17Alpha9, + friendListSchemaGraphQL17Alpha9, markAsStreaming, ObservableStream, promiseWithResolvers, @@ -29,66 +22,12 @@ import { // 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 friendType = new GraphQLObjectType({ - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - nonNullName: { type: new GraphQLNonNull(GraphQLString) }, - }, - name: "Friend", -}); - const friends = [ { name: "Luke", id: 1 }, { name: "Han", id: 2 }, { name: "Leia", id: 3 }, ]; -const query = new GraphQLObjectType({ - fields: { - scalarList: { - type: new GraphQLList(GraphQLString), - }, - scalarListList: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - friendList: { - type: new GraphQLList(friendType), - }, - 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", -}); - -const schema = new GraphQLSchema({ query }); - function resolveOnNextTick(): Promise { return Promise.resolve(undefined); } @@ -99,7 +38,7 @@ function run( enableEarlyExecution = false ) { return executeSchemaGraphQL17Alpha9( - schema, + friendListSchemaGraphQL17Alpha9, document, rootValue, enableEarlyExecution From 01cdf3eb5c849d024441fe4308243157a43dab12 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:48:17 -0600 Subject: [PATCH 90/97] Add cache tests for defer20220824 stream --- .../__tests__/defer20220824/stream.test.ts | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) diff --git a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts index 3dd497d67d1..13c09126dc9 100644 --- a/src/incremental/handlers/__tests__/defer20220824/stream.test.ts +++ b/src/incremental/handlers/__tests__/defer20220824/stream.test.ts @@ -1547,3 +1547,440 @@ test("Defer20220824Handler can be used with `ApolloClient`", async () => { 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); + } +}); From 1635680f78427a4e50c0ed40c9c61dce17a567cd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:55:21 -0600 Subject: [PATCH 91/97] Update stream tests to better handle React 18/19 differences --- .../streamGraphQL17Alpha9.test.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx index cba3e80d7fa..8eafae1220b 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamGraphQL17Alpha9.test.tsx @@ -1340,6 +1340,21 @@ test("adds partial data and does not throw errors returned in incremental chunks } 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"))); { @@ -1362,6 +1377,34 @@ test("adds partial data and does not throw errors returned in incremental chunks } 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(); { @@ -1426,6 +1469,21 @@ test("adds partial data and discards errors returned in incremental chunks with } 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"))); { @@ -1503,6 +1561,21 @@ test("can refetch and respond to cache updates after encountering an error in an } 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"))); { From e723622fbd664e1b40998279de97841a54e473b1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 16:57:14 -0600 Subject: [PATCH 92/97] Simplify merge function --- src/incremental/handlers/defer20220824.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/incremental/handlers/defer20220824.ts b/src/incremental/handlers/defer20220824.ts index bfa9a68d73b..27ce3d3c96d 100644 --- a/src/incremental/handlers/defer20220824.ts +++ b/src/incremental/handlers/defer20220824.ts @@ -75,12 +75,9 @@ class DeferRequest> private extensions: Record = {}; private data: any = {}; - 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) { this.errors.push(...normalized.errors); @@ -96,7 +93,7 @@ class DeferRequest> ): FormattedExecutionResult { this.hasNext = chunk.hasNext; this.data = cacheData; - this.merge(chunk, new DeepMerger()); + this.merge(chunk); if (hasIncrementalChunks(chunk)) { for (const incremental of chunk.incremental) { @@ -119,14 +116,11 @@ class DeferRequest> data = parent as typeof data; } } - this.merge( - { - errors, - extensions, - data: data ? (data as TData) : undefined, - }, - new DeepMerger() - ); + this.merge({ + errors, + extensions, + data: data ? (data as TData) : undefined, + }); } } From 447dd7d1e87c56ae6d3ca1167128c743b08fc750 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 17:07:53 -0600 Subject: [PATCH 93/97] Add missing args for friendList in alpha2 schema --- src/testing/internal/schemas/friendList.graphql17Alpha2.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/testing/internal/schemas/friendList.graphql17Alpha2.ts b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts index 07ab96da399..17d59da59a4 100644 --- a/src/testing/internal/schemas/friendList.graphql17Alpha2.ts +++ b/src/testing/internal/schemas/friendList.graphql17Alpha2.ts @@ -1,5 +1,6 @@ import { GraphQLID, + GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -26,6 +27,11 @@ const query = new GraphQLObjectType({ }, friendList: { type: new GraphQLList(friendType), + args: { + offset: { + type: GraphQLInt, + }, + }, }, nonNullFriendList: { type: new GraphQLList(new GraphQLNonNull(friendType)), From 2ab4a55127dcd4aad5174fdc25699c11dcee9f62 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 17:08:06 -0600 Subject: [PATCH 94/97] Update useSuspenseQuery tests to be more friendly between react versions --- .../streamDefer20220824.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx index 67c3f15d54c..0d2d441d4c5 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/streamDefer20220824.test.tsx @@ -1340,6 +1340,21 @@ test("adds partial data and does not throw errors returned in incremental chunks } 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")); { @@ -1426,6 +1441,21 @@ test("adds partial data and discards errors returned in incremental chunks with } 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")); { @@ -1503,6 +1533,21 @@ test("can refetch and respond to cache updates after encountering an error in an } 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")); { From c954d2531deb1b203993d124605fdeab9829826b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 21:56:57 -0600 Subject: [PATCH 95/97] Add useQuery stream tests for GraphQL17Alpha9Handler --- .../useQuery/streamGraphQL17Alpha9.test.tsx | 783 ++++++++++++++++++ 1 file changed, 783 insertions(+) create mode 100644 src/react/hooks/__tests__/useQuery/streamGraphQL17Alpha9.test.tsx 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(); +}); From 1206a77169ba7b2a1c16c92959769edf0772d440 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 11 Sep 2025 21:58:31 -0600 Subject: [PATCH 96/97] Add useQuery stream tests for Defer20220824Handler --- .../useQuery/streamDefer20220824.test.tsx | 783 ++++++++++++++++++ 1 file changed, 783 insertions(+) create mode 100644 src/react/hooks/__tests__/useQuery/streamDefer20220824.test.tsx 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(); +}); From b05df031912f77546d84b23a5ebebd2bee2ecae1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 12 Sep 2025 11:38:11 -0600 Subject: [PATCH 97/97] Add stream tests for useBackgroundQuery --- .../streamDefer20220824.test.tsx | 414 ++++++++++++++++++ .../streamGraphQL17Alpha9.test.tsx | 414 ++++++++++++++++++ 2 files changed, 828 insertions(+) create mode 100644 src/react/hooks/__tests__/useBackgroundQuery/streamDefer20220824.test.tsx create mode 100644 src/react/hooks/__tests__/useBackgroundQuery/streamGraphQL17Alpha9.test.tsx 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(); +});