diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index fce6521d2af..555c34eb762 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -5,21 +5,27 @@ import { ApolloError, ApolloQueryResult, DefaultOptions, + NetworkStatus, ObservableQuery, QueryOptions, makeReference, } from "../core"; import { Kind } from "graphql"; -import { DeepPartial, Observable } from "../utilities"; +import { DeepPartial, Observable, wrapPromiseWithState } from "../utilities"; import { ApolloLink, FetchResult } from "../link/core"; import { HttpLink } from "../link/http"; import { createFragmentRegistry, InMemoryCache } from "../cache"; -import { ObservableStream, spyOnConsole } from "../testing/internal"; +import { + mockDeferStream, + ObservableStream, + spyOnConsole, +} from "../testing/internal"; import { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { invariant } from "../utilities/globals"; import { expectTypeOf } from "expect-type"; import { Masked } from "../masking"; +import { wait } from "../testing"; describe("ApolloClient", () => { describe("constructor", () => { @@ -2649,6 +2655,117 @@ describe("ApolloClient", () => { }); }); + describe("query.firstChunk & waitForFragment", () => { + it("should resolve when the query's first chunk is available", async () => { + const defer = mockDeferStream(); + const client = new ApolloClient({ + link: defer.httpLink, + cache: new InMemoryCache(), + }); + + const HelloFragment: TypedDocumentNode<{ + world: { id: string; name: string }; + }> = gql` + fragment HelloFragment on Hello { + world { + id + name + } + } + `; + + const query: TypedDocumentNode<{ + hello: { id: string; world?: { id: string; name: string } }; + }> = gql` + query someData { + hello { + id + ...HelloFragment @defer + ... @defer { + somethingElse + } + } + } + ${HelloFragment} + `; + + const queryPromise = client.query({ + query, + }); + const queryStatus = wrapPromiseWithState(queryPromise); + + { + const details = wrapPromiseWithState(queryPromise.firstChunk); + await wait(10); + expect(details.status).toBe("pending"); + } + + defer.enqueueInitialChunk({ + hasNext: true, + data: { + hello: { + __typename: "Hello", + id: "1", + }, + }, + }); + + const firstChunkResult = await queryPromise.firstChunk; + + expect(firstChunkResult).toStrictEqual({ + data: { hello: { __typename: "Hello", id: "1" } }, + loading: false, + networkStatus: NetworkStatus.ready, + } satisfies ApolloQueryResult); + + const fragmentPromise = client.waitForFragment({ + fragment: HelloFragment, + from: firstChunkResult.data.hello, + }); + + { + const details = wrapPromiseWithState(fragmentPromise); + await wait(10); + expect(details.status).toBe("pending"); + } + + defer.enqueueSubsequentChunk({ + hasNext: true, + incremental: [ + { + path: ["hello"], + data: { + world: { + __typename: "World", + id: "100", + name: "Earth", + }, + }, + }, + ], + }); + + await expect(fragmentPromise).resolves.toStrictEqual({ + __typename: "Hello", + world: { __typename: "World", id: "100", name: "Earth" }, + }); + + expect(queryStatus.status).toBe("pending"); + + defer.enqueueSubsequentChunk({ + hasNext: false, + incremental: [ + { + path: ["hello"], + data: { somethingElse: true }, + }, + ], + }); + + await expect(queryPromise).resolves.toBeTruthy(); + }); + }); + describe("defaultOptions", () => { it( "should set `defaultOptions` to an empty object if not provided in " + diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 2b658e8e71f..3c37b94b22a 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -25,6 +25,7 @@ import type { InteropApolloQueryResult, InteropMutateResult, InteropSubscribeResult, + ApolloQueryResult, } from "./types.js"; import type { @@ -662,7 +663,9 @@ export class ApolloClient implements DataProxy { TVariables extends OperationVariables = OperationVariables, >( options: QueryOptions - ): Promise>> { + ): Promise>> & { + firstChunk: Promise>>; + } { if (this.defaultOptions.query) { options = mergeOptions(this.defaultOptions.query, options); } @@ -792,6 +795,28 @@ export class ApolloClient implements DataProxy { }); } + public waitForFragment< + TFragmentData = unknown, + TVariables = OperationVariables, + >( + options: WatchFragmentOptions + ): Promise { + return new Promise((resolve, reject) => { + const observable = this.watchFragment(options); + let subscription = observable.subscribe({ + next(result) { + if (result.complete) { + resolve(result.data); + subscription.unsubscribe(); + } + }, + error(err) { + reject(err); + }, + }); + }); + } + /** * Tries to read some data from the store in the shape of the provided * GraphQL fragment without making a network request. This method will read a diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 6699345606e..2860e8c86ed 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -802,7 +802,9 @@ export class QueryManager { public query( options: QueryOptions, queryId = this.generateQueryId() - ): Promise>> { + ): Promise>> & { + firstChunk: Promise>>; + } { invariant( options.query, "query option is required. You must specify your GraphQL document " + @@ -826,20 +828,51 @@ export class QueryManager { const query = this.transform(options.query); - return this.fetchQuery(queryId, { ...options, query }) - .then( - (result) => - result && { - ...result, - data: this.maskOperation({ - document: query, - data: result.data, - fetchPolicy: options.fetchPolicy, - id: queryId, - }), - } - ) - .finally(() => this.stopQuery(queryId)); + const concast = this.fetchConcastWithInfo(this.getOrCreateQuery(queryId), { + ...options, + query, + }).concast as Concast>; + let resolve!: ( + value: ApolloQueryResult | PromiseLike> + ) => void, + reject!: (reason?: any) => void; + const firstChunkPromise = new Promise>( + (res, rej) => { + resolve = res; + reject = rej; + } + ); + + concast.addObserver({ + next(value) { + if (!value.loading) resolve(value); + }, + error(errorValue) { + reject(errorValue); + }, + complete() { + reject( + new Error("The query never received a result before finishing.") + ); + }, + }); + return Object.assign( + (concast.promise as Promise>) + .then( + (result) => + result && { + ...result, + data: this.maskOperation({ + document: query, + data: result.data, + fetchPolicy: options.fetchPolicy, + id: queryId, + }), + } + ) + .finally(() => this.stopQuery(queryId)), + { firstChunk: firstChunkPromise } + ); } private queryIdCounter = 1;