Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 119 additions & 2 deletions src/__tests__/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<any>);

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 " +
Expand Down
27 changes: 26 additions & 1 deletion src/core/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
InteropApolloQueryResult,
InteropMutateResult,
InteropSubscribeResult,
ApolloQueryResult,
} from "./types.js";

import type {
Expand Down Expand Up @@ -662,7 +663,9 @@ export class ApolloClient<TCacheShape = any> implements DataProxy {
TVariables extends OperationVariables = OperationVariables,
>(
options: QueryOptions<TVariables, T>
): Promise<InteropApolloQueryResult<MaybeMasked<T>>> {
): Promise<InteropApolloQueryResult<MaybeMasked<T>>> & {
firstChunk: Promise<ApolloQueryResult<MaybeMasked<T>>>;
} {
if (this.defaultOptions.query) {
options = mergeOptions(this.defaultOptions.query, options);
}
Expand Down Expand Up @@ -792,6 +795,28 @@ export class ApolloClient<TCacheShape = any> implements DataProxy {
});
}

public waitForFragment<
TFragmentData = unknown,
TVariables = OperationVariables,
>(
options: WatchFragmentOptions<TFragmentData, TVariables>
): Promise<TFragmentData> {
return new Promise<TFragmentData>((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
Expand Down
63 changes: 48 additions & 15 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,9 @@ export class QueryManager<TStore> {
public query<TData, TVars extends OperationVariables = OperationVariables>(
options: QueryOptions<TVars, TData>,
queryId = this.generateQueryId()
): Promise<ApolloQueryResult<MaybeMasked<TData>>> {
): Promise<ApolloQueryResult<MaybeMasked<TData>>> & {
firstChunk: Promise<ApolloQueryResult<MaybeMasked<TData>>>;
} {
invariant(
options.query,
"query option is required. You must specify your GraphQL document " +
Expand All @@ -826,20 +828,51 @@ export class QueryManager<TStore> {

const query = this.transform(options.query);

return this.fetchQuery<TData, TVars>(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<ApolloQueryResult<TData>>;
let resolve!: (
value: ApolloQueryResult<TData> | PromiseLike<ApolloQueryResult<TData>>
) => void,
reject!: (reason?: any) => void;
const firstChunkPromise = new Promise<ApolloQueryResult<TData>>(
(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<ApolloQueryResult<TData>>)
.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;
Expand Down