diff --git a/.gitignore b/.gitignore
index a2688e5c01d..c133c77c1e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,9 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
+# Ignore Wallaby.js configuration file
+wallaby.js
+
# Coverage directory used by tools like istanbul
coverage
@@ -61,4 +64,7 @@ junit.xml
.rpt2_cache
# Local Netlify folder
-.netlify
\ No newline at end of file
+.netlify
+
+# Ignore generated test report output
+reports
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 4ab27d98c2e..f8ace00ded3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,7 +4,7 @@
"editor.rulers": [80],
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
- "typescript.tsdk": "../node_modules/typescript/lib",
+ "typescript.tsdk": "node_modules/typescript/lib",
"cSpell.enableFiletypes": [
"mdx"
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77c7360e02a..27b30b43414 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,11 @@
- Delay calling `onCompleted` and `onError` callbacks passed to `useQuery` using `Promise.resolve().then(() => ...)` to fix issue [#9794](https://github.com/apollographql/apollo-client/pull/9794).
[@dylanwulf](https://github.com/dylanwulf) in [#9823](https://github.com/apollographql/apollo-client/pull/9823)
+### Potentially disruptive
+
+- The optional `subscribeAndCount` testing utility exported from `@apollo/client/testing/core` now takes a single generic `TResult` type parameter, instead of `TData`. This type will typically be inferred from the `observable` argument type, but if you have any explicit calls to `subscribeAndCount(...)` in your own codebase, you may need to adjust those calls accordingly.
+ [@benjamn](https://github.com/benjamn) in [#9718](https://github.com/apollographql/apollo-client/pull/9718)
+
## Apollo Client 3.6.9 (2022-06-21)
### Bug Fixes
diff --git a/package-lock.json b/package-lock.json
index c86738b59c5..4f0d8b046c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"hoist-non-react-statics": "^3.3.2",
"optimism": "^0.16.1",
"prop-types": "^15.7.2",
+ "response-iterator": "^0.2.6",
"symbol-observable": "^4.0.0",
"ts-invariant": "^0.10.3",
"tslib": "^2.3.0",
@@ -36,10 +37,12 @@
"@types/jest": "27.5.2",
"@types/lodash": "4.14.182",
"@types/node": "16.11.41",
+ "@types/node-fetch": "2.6.2",
"@types/react": "17.0.47",
"@types/react-dom": "17.0.17",
- "@types/use-sync-external-store": "^0.0.3",
+ "@types/use-sync-external-store": "0.0.3",
"acorn": "8.7.1",
+ "blob-polyfill": "7.0.20220408",
"bundlesize": "0.18.1",
"cross-fetch": "3.1.5",
"crypto-hash": "1.3.0",
@@ -65,6 +68,7 @@
"ts-node": "10.8.1",
"typescript": "4.6.4",
"wait-for-observables": "1.0.3",
+ "web-streams-polyfill": "3.2.1",
"whatwg-fetch": "3.6.2"
},
"engines": {
@@ -1389,6 +1393,16 @@
"integrity": "sha512-mqoYK2TnVjdkGk8qXAVGc/x9nSaTpSrFaGFm43BUH3IdoBV0nta6hYaGmdOvIMlbHJbUEVen3gvwpwovAZKNdQ==",
"dev": true
},
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
"node_modules/@types/prettier": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz",
@@ -1822,6 +1836,12 @@
"node": ">= 6"
}
},
+ "node_modules/blob-polyfill": {
+ "version": "7.0.20220408",
+ "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz",
+ "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==",
+ "dev": true
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -5271,6 +5291,14 @@
"node": ">=10"
}
},
+ "node_modules/response-iterator": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz",
+ "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -6174,6 +6202,15 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+ "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@@ -7524,6 +7561,16 @@
"integrity": "sha512-mqoYK2TnVjdkGk8qXAVGc/x9nSaTpSrFaGFm43BUH3IdoBV0nta6hYaGmdOvIMlbHJbUEVen3gvwpwovAZKNdQ==",
"dev": true
},
+ "@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
"@types/prettier": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz",
@@ -7875,6 +7922,12 @@
}
}
},
+ "blob-polyfill": {
+ "version": "7.0.20220408",
+ "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz",
+ "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==",
+ "dev": true
+ },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -10549,6 +10602,11 @@
"integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==",
"dev": true
},
+ "response-iterator": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz",
+ "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw=="
+ },
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -11232,6 +11290,12 @@
"makeerror": "1.0.12"
}
},
+ "web-streams-polyfill": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+ "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+ "dev": true
+ },
"webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
diff --git a/package.json b/package.json
index 7e0df8c8f7a..cfd95948066 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.min.cjs",
- "maxSize": "29.95kB"
+ "maxSize": "31.4kB"
}
],
"engines": {
@@ -88,6 +88,7 @@
"hoist-non-react-statics": "^3.3.2",
"optimism": "^0.16.1",
"prop-types": "^15.7.2",
+ "response-iterator": "^0.2.6",
"symbol-observable": "^4.0.0",
"ts-invariant": "^0.10.3",
"tslib": "^2.3.0",
@@ -107,10 +108,12 @@
"@types/jest": "27.5.2",
"@types/lodash": "4.14.182",
"@types/node": "16.11.41",
+ "@types/node-fetch": "2.6.2",
"@types/react": "17.0.47",
"@types/react-dom": "17.0.17",
- "@types/use-sync-external-store": "^0.0.3",
+ "@types/use-sync-external-store": "0.0.3",
"acorn": "8.7.1",
+ "blob-polyfill": "7.0.20220408",
"bundlesize": "0.18.1",
"cross-fetch": "3.1.5",
"crypto-hash": "1.3.0",
@@ -136,6 +139,7 @@
"ts-node": "10.8.1",
"typescript": "4.6.4",
"wait-for-observables": "1.0.3",
+ "web-streams-polyfill": "3.2.1",
"whatwg-fetch": "3.6.2"
},
"publishConfig": {
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap
index 9de8e082f4e..2c65b769b11 100644
--- a/src/__tests__/__snapshots__/exports.ts.snap
+++ b/src/__tests__/__snapshots__/exports.ts.snap
@@ -350,6 +350,7 @@ Array [
"argumentsObjectFromField",
"asyncMap",
"buildQueryFromSelectionSet",
+ "canUseAsyncIteratorSymbol",
"canUseDOM",
"canUseLayoutEffect",
"canUseSymbol",
diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts
index f896b927483..c1b1e255409 100644
--- a/src/core/ObservableQuery.ts
+++ b/src/core/ObservableQuery.ts
@@ -482,7 +482,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`);
},
});
- return fetchMoreResult as ApolloQueryResult;
+ return fetchMoreResult;
}).finally(() => {
// In case the cache writes above did not generate a broadcast
diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts
index a5dbfecba47..bc2a7bc9118 100644
--- a/src/core/QueryInfo.ts
+++ b/src/core/QueryInfo.ts
@@ -2,6 +2,7 @@ import { DocumentNode, GraphQLError } from 'graphql';
import { equal } from "@wry/equality";
import { Cache, ApolloCache } from '../cache';
+import { DeepMerger } from "../utilities"
import { WatchQueryOptions, ErrorPolicy } from './watchQueryOptions';
import { ObservableQuery, reobserveCacheFirst } from './ObservableQuery';
import { QueryListener } from './types';
@@ -153,7 +154,6 @@ export class QueryInfo {
reset() {
cancelNotifyTimeout(this);
- this.lastDiff = void 0;
this.dirty = false;
}
@@ -362,12 +362,35 @@ export class QueryInfo {
| "errorPolicy">,
cacheWriteBehavior: CacheWriteBehavior,
) {
- this.graphQLErrors = isNonEmptyArray(result.errors) ? result.errors : [];
+ const graphQLErrors = isNonEmptyArray(result.errors)
+ ? result.errors.slice(0)
+ : [];
// Cancel the pending notify timeout (if it exists) to prevent extraneous network
// requests. To allow future notify timeouts, diff and dirty are reset as well.
this.reset();
+ if ('incremental' in result && isNonEmptyArray(result.incremental)) {
+ let mergedData = this.getDiff().result;
+ const merger = new DeepMerger();
+ result.incremental.forEach(({ data, path, errors }) => {
+ for (let i = path.length - 1; i >= 0; --i) {
+ const key = path[i];
+ const isNumericKey = !isNaN(+key);
+ const parent: Record = isNumericKey ? [] : {};
+ parent[key] = data;
+ data = parent as typeof data;
+ }
+ if (errors) {
+ graphQLErrors.push(...errors);
+ }
+ mergedData = merger.merge(mergedData, data);
+ });
+ result.data = mergedData;
+ }
+
+ this.graphQLErrors = graphQLErrors;
+
if (options.fetchPolicy === 'no-cache') {
this.updateLastDiff(
{ result: result.data, complete: true },
diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts
index ee4c31edc1b..04bec55c54e 100644
--- a/src/core/QueryManager.ts
+++ b/src/core/QueryManager.ts
@@ -6,6 +6,7 @@ type OperationTypeNode = any;
import { equal } from '@wry/equality';
import { ApolloLink, execute, FetchResult } from '../link/core';
+import { isExecutionPatchIncrementalResult } from '../utilities/common/incrementalResult';
import { Cache, ApolloCache, canonicalStringify } from '../cache';
import {
@@ -434,7 +435,7 @@ export class QueryManager {
returnPartialData: true,
});
- if (diff.complete) {
+ if (diff.complete && !(isExecutionPatchIncrementalResult(result))) {
result = { ...result, data: diff.result };
}
}
@@ -1047,7 +1048,19 @@ export class QueryManager {
),
result => {
- const hasErrors = isNonEmptyArray(result.errors);
+ const graphQLErrors = isNonEmptyArray(result.errors)
+ ? result.errors.slice(0)
+ : [];
+
+ if ('incremental' in result && isNonEmptyArray(result.incremental)) {
+ result.incremental.forEach(incrementalResult => {
+ if (incrementalResult.errors) {
+ graphQLErrors.push(...incrementalResult.errors);
+ }
+ });
+ }
+
+ const hasErrors = isNonEmptyArray(graphQLErrors);
// If we interrupted this request by calling getResultsFromLink again
// with the same QueryInfo object, we ignore the old results.
@@ -1055,7 +1068,7 @@ export class QueryManager {
if (hasErrors && options.errorPolicy === "none") {
// Throwing here effectively calls observer.error.
throw queryInfo.markError(new ApolloError({
- graphQLErrors: result.errors,
+ graphQLErrors,
}));
}
queryInfo.markResult(result, options, cacheWriteBehavior);
@@ -1069,7 +1082,7 @@ export class QueryManager {
};
if (hasErrors && options.errorPolicy !== "ignore") {
- aqr.errors = result.errors;
+ aqr.errors = graphQLErrors;
aqr.networkStatus = NetworkStatus.error;
}
diff --git a/src/link/core/types.ts b/src/link/core/types.ts
index 847cf626816..a81038da00a 100644
--- a/src/link/core/types.ts
+++ b/src/link/core/types.ts
@@ -1,7 +1,60 @@
-import { DocumentNode, ExecutionResult } from 'graphql';
+import { DocumentNode, ExecutionResult, GraphQLError } from "graphql";
export { DocumentNode };
-import { Observable } from '../../utilities';
+import { Observable } from "../../utilities";
+
+export type Path = ReadonlyArray;
+type Data = T | null | undefined;
+
+
+interface ExecutionPatchResultBase {
+ hasNext?: boolean;
+}
+
+export interface ExecutionPatchInitialResult<
+ TData = Record,
+ TExtensions = Record
+> extends ExecutionPatchResultBase {
+ // if data is present, incremental is not
+ data: Data;
+ incremental?: never;
+ errors?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+export interface IncrementalPayload<
+ TData,
+ TExtensions,
+> {
+ // data and path must both be present
+ // https://github.com/graphql/graphql-spec/pull/742/files#diff-98d0cd153b72b63c417ad4238e8cc0d3385691ccbde7f7674bc0d2a718b896ecR288-R293
+ data: Data;
+ label?: string;
+ path: Path;
+ errors?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+export interface ExecutionPatchIncrementalResult<
+ TData = Record,
+ TExtensions = Record
+> extends ExecutionPatchResultBase {
+ // the reverse is also true: if incremental is present,
+ // data (and errors and extensions) are not
+ incremental?: IncrementalPayload[];
+ data?: never;
+ // Errors only exist for chunks, not at the top level
+ // https://github.com/robrichard/defer-stream-wg/discussions/50#discussioncomment-3466739
+ errors?: never;
+ extensions?: never;
+}
+
+export type ExecutionPatchResult<
+ TData = Record,
+ TExtensions = Record
+> =
+ | ExecutionPatchInitialResult
+ | ExecutionPatchIncrementalResult;
export interface GraphQLRequest {
query: DocumentNode;
@@ -20,19 +73,26 @@ export interface Operation {
getContext: () => Record;
}
-export interface FetchResult<
+export interface SingleExecutionResult<
TData = Record,
TContext = Record,
TExtensions = Record
> extends ExecutionResult {
- data?: TData | null | undefined;
- extensions?: TExtensions;
+ data?: Data;
context?: TContext;
-};
+}
+
+export type FetchResult<
+ TData = Record,
+ TContext = Record,
+ TExtensions = Record
+> =
+ | SingleExecutionResult
+ | ExecutionPatchResult;
export type NextLink = (operation: Operation) => Observable;
export type RequestHandler = (
operation: Operation,
- forward: NextLink,
+ forward: NextLink
) => Observable | null;
diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts
index 7114770ae32..eefa00607d8 100644
--- a/src/link/http/__tests__/HttpLink.ts
+++ b/src/link/http/__tests__/HttpLink.ts
@@ -1,6 +1,9 @@
import gql from 'graphql-tag';
import fetchMock from 'fetch-mock';
import { ASTNode, print, stripIgnoredCharacters } from 'graphql';
+import { TextDecoder } from 'util';
+import { ReadableStream } from 'web-streams-polyfill/ponyfill/es2018';
+import { Readable } from 'stream';
import { Observable, Observer, ObservableSubscription } from '../../../utilities/observables/Observable';
import { ApolloLink } from '../../core/ApolloLink';
@@ -29,6 +32,28 @@ const sampleMutation = gql`
}
`;
+const sampleDeferredQuery = gql`
+ query SampleDeferredQuery {
+ stub {
+ id
+ ... on Stub @defer {
+ name
+ }
+ }
+ }
+`;
+
+const sampleQueryCustomDirective = gql`
+ query SampleDeferredQuery {
+ stub {
+ id
+ ... on Stub @deferCustomDirective {
+ name
+ }
+ }
+ }
+`;
+
function makeCallback(
resolve: () => void,
reject: (error: Error) => void,
@@ -1248,4 +1273,217 @@ describe('HttpLink', () => {
);
});
});
+
+ describe('Multipart responses', () => {
+ let originalTextDecoder: any;
+ beforeAll(() => {
+ originalTextDecoder = TextDecoder;
+ (globalThis as any).TextDecoder = TextDecoder;
+ });
+
+ afterAll(() => {
+ globalThis.TextDecoder = originalTextDecoder;
+ });
+
+ const body = [
+ '---',
+ 'Content-Type: application/json; charset=utf-8',
+ 'Content-Length: 43',
+ '',
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ '---',
+ 'Content-Type: application/json; charset=utf-8',
+ 'Content-Length: 58',
+ '',
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"],"extensions":{"timestamp":1633038919}}]}',
+ '-----',
+ ].join("\r\n");
+
+ it('can handle whatwg stream bodies', (done) => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ const lines = body.split("\r\n");
+ try {
+ for (const line of lines) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ controller.enqueue(line + "\r\n");
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({ 'content-type': 'multipart/mixed' }),
+ }));
+
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ let i = 0;
+ execute(link, { query: sampleDeferredQuery }).subscribe(
+ result => {
+ try {
+ if (i === 0) {
+ expect(result).toEqual({
+ data: {
+ stub: {
+ id: "0",
+ },
+ },
+ hasNext: true,
+ });
+ } else if (i === 1) {
+ expect(result).toEqual({
+ incremental: [{
+ data: {
+ name: 'stubby',
+ },
+ extensions: {
+ timestamp: 1633038919,
+ },
+ path: ['stub'],
+ }],
+ hasNext: false,
+ });
+ }
+
+ } catch (err) {
+ done(err);
+ } finally {
+ i++;
+ }
+ },
+ err => {
+ done(err);
+ },
+ () => {
+ if (i !== 2) {
+ done(new Error("Unexpected end to observable"));
+ }
+
+ done();
+ },
+ );
+ });
+
+ it('can handle node stream bodies', (done) => {
+ const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n"));
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({ 'Content-Type': 'multipart/mixed;boundary="-";deferSpec=20220824' }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ let i = 0;
+ execute(link, { query: sampleDeferredQuery }).subscribe(
+ result => {
+ try {
+ if (i === 0) {
+ expect(result).toEqual({
+ data: {
+ stub: {
+ id: "0",
+ },
+ },
+ hasNext: true,
+ });
+ } else if (i === 1) {
+ expect(result).toEqual({
+ incremental: [{
+ data: {
+ name: 'stubby',
+ },
+ extensions: {
+ timestamp: 1633038919,
+ },
+ path: ['stub'],
+ }],
+ hasNext: false,
+ });
+ }
+
+ } catch (err) {
+ done(err);
+ } finally {
+ i++;
+ }
+ },
+ err => {
+ done(err);
+ },
+ () => {
+ if (i !== 2) {
+ done(new Error("Unexpected end to observable"));
+ }
+
+ done();
+ },
+ );
+ });
+
+ itAsync('sets correct accept header on request with deferred query', (resolve, reject) => {
+ const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n"));
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({ 'Content-Type': 'multipart/mixed' }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+ execute(link, {
+ query: sampleDeferredQuery
+ }).subscribe(
+ makeCallback(resolve, reject, () => {
+ expect(fetch).toHaveBeenCalledWith(
+ '/graphql',
+ expect.objectContaining({
+ headers: {
+ "content-type": "application/json",
+ accept: "multipart/mixed; deferSpec=20220824, application/json"
+ }
+ })
+ )
+ }),
+ );
+ });
+
+ // ensure that custom directives beginning with '@defer..' do not trigger
+ // custom accept header for multipart responses
+ itAsync('sets does not set accept header on query with custom directive begging with @defer', (resolve, reject) => {
+ const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n"));
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({ 'Content-Type': 'multipart/mixed' }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+ execute(link, {
+ query: sampleQueryCustomDirective
+ }).subscribe(
+ makeCallback(resolve, reject, () => {
+ expect(fetch).toHaveBeenCalledWith(
+ '/graphql',
+ expect.objectContaining({
+ headers: {
+ accept: "*/*",
+ "content-type": "application/json",
+ }
+ })
+ )
+ }),
+ );
+ });
+ });
});
diff --git a/src/link/http/__tests__/responseIterator.ts b/src/link/http/__tests__/responseIterator.ts
new file mode 100644
index 00000000000..df263f51370
--- /dev/null
+++ b/src/link/http/__tests__/responseIterator.ts
@@ -0,0 +1,510 @@
+import gql from "graphql-tag";
+import { execute } from "../../core/execute";
+import { HttpLink } from "../HttpLink";
+import { itAsync, subscribeAndCount } from "../../../testing";
+import type { Observable } from "zen-observable-ts";
+import { ObservableQuery } from "../../../core";
+import { TextEncoder, TextDecoder } from "util";
+import { ReadableStream } from "web-streams-polyfill/ponyfill/es2018";
+import { Readable } from "stream";
+
+var Blob = require('blob-polyfill').Blob;
+
+function makeCallback(
+ resolve: () => void,
+ reject: (error: Error) => void,
+ callback: (...args: TArgs) => any,
+) {
+ return function () {
+ try {
+ callback.apply(this, arguments);
+ resolve();
+ } catch (error) {
+ reject(error);
+ }
+ } as typeof callback;
+}
+
+const sampleDeferredQuery = gql`
+ query SampleDeferredQuery {
+ stub {
+ id
+ ... on Stub @defer {
+ name
+ }
+ }
+ }
+`;
+
+const BOUNDARY = "gc0p4Jq0M2Yt08jU534c0p";
+
+function matchesResults(
+ resolve: () => void,
+ reject: (err: any) => void,
+ observable: Observable,
+ results: Array
+) {
+ // TODO: adding a second observer to the observable will consume the
+ // observable. I want to test completion, but the subscribeAndCount API
+ // doesn’t have anything like that.
+ subscribeAndCount(
+ reject,
+ observable as unknown as ObservableQuery,
+ (count, result) => {
+ // subscribeAndCount is 1-indexed for some terrible reason.
+ if (0 >= count || count > results.length) {
+ reject(new Error("Unexpected result"));
+ }
+
+ expect(result).toEqual(results[count - 1]);
+ if (count === results.length) {
+ resolve();
+ }
+ }
+ );
+}
+
+describe("multipart responses", () => {
+ let originalTextDecoder: any;
+ beforeAll(() => {
+ originalTextDecoder = TextDecoder;
+ (globalThis as any).TextDecoder = TextDecoder;
+ });
+
+ afterAll(() => {
+ globalThis.TextDecoder = originalTextDecoder;
+ });
+
+ const bodyCustomBoundary = [
+ `--${BOUNDARY}`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 43",
+ "",
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ `--${BOUNDARY}`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 58",
+ "",
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}',
+ `--${BOUNDARY}--`,
+ ].join("\r\n");
+
+ const bodyDefaultBoundary = [
+ `---`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 43",
+ "",
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ `---`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 58",
+ "",
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}',
+ `-----`,
+ ].join("\r\n");
+
+ const bodyIncorrectChunkType = [
+ `---`,
+ "Content-Type: foo/bar; charset=utf-8",
+ "Content-Length: 43",
+ "",
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ `---`,
+ "Content-Type: foo/bar; charset=utf-8",
+ "Content-Length: 58",
+ "",
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}',
+ `-----`,
+ ].join("\r\n");
+
+ const bodyBatchedResults = [
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"data":{"allProducts":[{"delivery":{"__typename":"DeliveryEstimates"},"sku":"federation","id":"apollo-federation","__typename":"Product"},{"delivery":{"__typename":"DeliveryEstimates"},"sku":"studio","id":"apollo-studio","__typename":"Product"}]},"hasNext":true}',
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"hasNext":true,"incremental":[{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",0,"delivery"]},{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",1,"delivery"]}]}',
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"hasNext":false}',
+ "--graphql--",
+ ].join("\r\n");
+
+ const results = [
+ {
+ data: {
+ stub: {
+ id: "0",
+ },
+ },
+ hasNext: true,
+ },
+ {
+ incremental: [
+ {
+ data: {
+ name: "stubby",
+ },
+ path: ["stub"],
+ },
+ ],
+ hasNext: false,
+ },
+ ];
+
+ const batchedResults = [
+ {
+ data: {
+ allProducts: [
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ },
+ id: "apollo-federation",
+ sku: "federation",
+ },
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ },
+ id: "apollo-studio",
+ sku: "studio",
+ },
+ ],
+ },
+ hasNext: true,
+ },
+ {
+ hasNext: true,
+ incremental: [
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: ["allProducts", 0, "delivery"],
+ },
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: ["allProducts", 1, "delivery"],
+ },
+ ],
+ },
+ ];
+
+ itAsync("can handle whatwg stream bodies", (resolve, reject) => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ const lines = bodyCustomBoundary.split("\r\n");
+ try {
+ for (const line of lines) {
+ controller.enqueue(line + "\r\n");
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ });
+
+ itAsync(
+ "can handle whatwg stream bodies with arbitrary splits",
+ (resolve, reject) => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ let chunks: Array = [];
+ let chunkSize = 15;
+ for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) {
+ chunks.push(bodyCustomBoundary.slice(i, i + chunkSize));
+ }
+
+ try {
+ for (const chunk of chunks) {
+ controller.enqueue(chunk);
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (strings) with default boundary",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyDefaultBoundary.split("\r\n").map((line) => line + "\r\n")
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "content-type": `multipart/mixed`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (strings) with arbitrary splits",
+ (resolve, reject) => {
+ let chunks: Array = [];
+ let chunkSize = 15;
+ for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) {
+ chunks.push(bodyCustomBoundary.slice(i, i + chunkSize));
+ }
+ const stream = Readable.from(chunks);
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (array buffers)",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyDefaultBoundary
+ .split("\r\n")
+ .map((line) => new TextEncoder().encode(line + "\r\n"))
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "content-type": `multipart/mixed`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (array buffers) with batched results",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyBatchedResults
+ .split("\r\n")
+ .map((line) => new TextEncoder().encode(line + "\r\n"))
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "content-type": `multipart/mixed;boundary="graphql";deferSpec=20220824`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, batchedResults);
+ }
+ );
+
+ itAsync(
+ "can handle streamable blob bodies",
+ (resolve, reject) => {
+ const body = new Blob(bodyCustomBoundary.split("\r\n"), { type: "application/text" });
+ const stream = new ReadableStream({
+ async start(controller) {
+ const lines = bodyCustomBoundary.split("\r\n");
+ try {
+ for (const line of lines) {
+ controller.enqueue(line + "\r\n");
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+ body.stream = () => stream;
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle non-streamable blob bodies",
+ (resolve, reject) => {
+ const body = new Blob(bodyCustomBoundary.split("\r\n").map(i => i + "\r\n"), { type: "application/text" });
+ body.stream = undefined;
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body,
+ headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync('throws error on non-streamable body', (resolve, reject) => {
+ // non-streamable body
+ const body = 12345;
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body,
+ headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+ const observable = execute(link, { query: sampleDeferredQuery });
+ const mockError = { throws: new Error('Unknown body type for responseIterator. Please pass a streamable response.') };
+
+ observable.subscribe(
+ () => reject('next should not have been called'),
+ makeCallback(resolve, reject, (error) => {
+ expect(error).toEqual(mockError.throws);
+ }),
+ () => reject('complete should not have been called'),
+ );
+ });
+
+ // test is still failing as observer.complete is called even after error is thrown
+ // itAsync('throws error on unsupported patch content type', (resolve, reject) => {
+ // const stream = Readable.from(
+ // bodyIncorrectChunkType.split("\r\n").map((line) => line + "\r\n")
+ // );
+ // const fetch = jest.fn(async () => ({
+ // status: 200,
+ // body: stream,
+ // headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }),
+ // }));
+ // const link = new HttpLink({
+ // fetch: fetch as any,
+ // });
+ // const observable = execute(link, { query: sampleDeferredQuery });
+ // const mockError = { throws: new Error('Unsupported patch content type: application/json is required') };
+
+ // observable.subscribe(
+ // () => reject('next should not have been called'),
+ // makeCallback(resolve, reject, (error) => {
+ // expect(error).toEqual(mockError.throws);
+ // }),
+ // () => reject('complete should not have been called'),
+ // );
+ // });
+
+ describe('without TextDecoder defined in the environment', () => {
+ beforeAll(() => {
+ originalTextDecoder = TextDecoder;
+ (globalThis as any).TextDecoder = undefined;
+ });
+
+ afterAll(() => {
+ globalThis.TextDecoder = originalTextDecoder;
+ });
+
+ itAsync('throws error if TextDecoder not defined in the environment', (resolve, reject) => {
+ const stream = Readable.from(
+ bodyIncorrectChunkType.split("\r\n").map((line) => line + "\r\n")
+ );
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+ const observable = execute(link, { query: sampleDeferredQuery });
+ const mockError = { throws: new Error('TextDecoder must be defined in the environment: please import a polyfill.') };
+
+ observable.subscribe(
+ () => reject('next should not have been called'),
+ makeCallback(resolve, reject, (error) => {
+ expect(error).toEqual(mockError.throws);
+ }),
+ () => reject('complete should not have been called'),
+ );
+ });
+ })
+});
diff --git a/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts
new file mode 100644
index 00000000000..bab9a6024d5
--- /dev/null
+++ b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts
@@ -0,0 +1,356 @@
+import gql from "graphql-tag";
+import { execute } from "../../core/execute";
+import { HttpLink } from "../HttpLink";
+import { itAsync, subscribeAndCount } from "../../../testing";
+import type { Observable } from "zen-observable-ts";
+import { TextEncoder, TextDecoder } from "util";
+import { ReadableStream } from "web-streams-polyfill/ponyfill/es2018";
+import { Readable } from "stream";
+
+// As of Jest 26 there is no way to mock/unmock a module that is used indirectly
+// via a single test file.
+// These tests duplicate __tests__/responseIterator.ts while mocking
+// `isAsyncIterableIterator = false` in order to test the integration of the
+// implementations inside /iterators via src/link/http/responseIterator.ts
+// which do not execute when isAsyncIterableIterator is true
+// See: https://github.com/facebook/jest/issues/2582#issuecomment-655110424
+
+jest.mock("../../../utilities/common/responseIterator", () => ({
+ __esModule: true,
+ ...jest.requireActual("../../../utilities/common/responseIterator"),
+ isAsyncIterableIterator: jest.fn(() => false),
+}));
+
+const sampleDeferredQuery = gql`
+ query SampleDeferredQuery {
+ stub {
+ id
+ ... on Stub @defer {
+ name
+ }
+ }
+ }
+`;
+
+const BOUNDARY = "gc0p4Jq0M2Yt08jU534c0p";
+
+function matchesResults(
+ resolve: () => void,
+ reject: (err: any) => void,
+ observable: Observable,
+ results: Array
+) {
+ // TODO: adding a second observer to the observable will consume the
+ // observable. I want to test completion, but the subscribeAndCount API
+ // doesn’t have anything like that.
+ subscribeAndCount(
+ reject,
+ observable,
+ (count, result) => {
+ // subscribeAndCount is 1-indexed for some terrible reason.
+ if (0 >= count || count > results.length) {
+ reject(new Error("Unexpected result"));
+ }
+
+ expect(result).toEqual(results[count - 1]);
+ if (count === results.length) {
+ resolve();
+ }
+ }
+ );
+}
+
+describe("multipart responses", () => {
+ let originalTextDecoder: any;
+ beforeAll(() => {
+ originalTextDecoder = TextDecoder;
+ (globalThis as any).TextDecoder = TextDecoder;
+ });
+
+ afterAll(() => {
+ globalThis.TextDecoder = originalTextDecoder;
+ });
+
+ const bodyCustomBoundary = [
+ `--${BOUNDARY}`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 43",
+ "",
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ `--${BOUNDARY}`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 58",
+ "",
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}',
+ `--${BOUNDARY}--`,
+ ].join("\r\n");
+
+ const bodyDefaultBoundary = [
+ `---`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 43",
+ "",
+ '{"data":{"stub":{"id":"0"}},"hasNext":true}',
+ `---`,
+ "Content-Type: application/json; charset=utf-8",
+ "Content-Length: 58",
+ "",
+ '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}',
+ `-----`,
+ ].join("\r\n");
+
+ const bodyBatchedResults = [
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"data":{"allProducts":[{"delivery":{"__typename":"DeliveryEstimates"},"sku":"federation","id":"apollo-federation","__typename":"Product"},{"delivery":{"__typename":"DeliveryEstimates"},"sku":"studio","id":"apollo-studio","__typename":"Product"}]},"hasNext":true}',
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"hasNext":true,"incremental":[{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",0,"delivery"]},{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",1,"delivery"]}]}',
+ "--graphql",
+ "content-type: application/json",
+ "",
+ '{"hasNext":false}',
+ "--graphql--",
+ ].join("\r\n");
+
+ const results = [
+ {
+ data: {
+ stub: {
+ id: "0",
+ },
+ },
+ hasNext: true,
+ },
+ {
+ incremental: [
+ {
+ data: {
+ name: "stubby",
+ },
+ path: ["stub"],
+ },
+ ],
+ hasNext: false,
+ },
+ ];
+
+ const batchedResults = [
+ {
+ data: {
+ allProducts: [
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ },
+ id: "apollo-federation",
+ sku: "federation",
+ },
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ },
+ id: "apollo-studio",
+ sku: "studio",
+ },
+ ],
+ },
+ hasNext: true,
+ },
+ {
+ hasNext: true,
+ incremental: [
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: ["allProducts", 0, "delivery"],
+ },
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: ["allProducts", 1, "delivery"],
+ },
+ ],
+ },
+ ];
+
+ itAsync("can handle whatwg stream bodies", (resolve, reject) => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ const lines = bodyCustomBoundary.split("\r\n");
+ try {
+ for (const line of lines) {
+ controller.enqueue(line + "\r\n");
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ });
+
+ itAsync(
+ "can handle whatwg stream bodies with arbitrary splits",
+ (resolve, reject) => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ let chunks: Array = [];
+ let chunkSize = 15;
+ for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) {
+ chunks.push(bodyCustomBoundary.slice(i, i + chunkSize));
+ }
+
+ try {
+ for (const chunk of chunks) {
+ controller.enqueue(chunk);
+ }
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (strings) with default boundary",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyDefaultBoundary.split("\r\n").map((line) => line + "\r\n")
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "content-type": `multipart/mixed`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (strings) with arbitrary splits",
+ (resolve, reject) => {
+ let chunks: Array = [];
+ let chunkSize = 15;
+ for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) {
+ chunks.push(bodyCustomBoundary.slice(i, i + chunkSize));
+ }
+ const stream = Readable.from(chunks);
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ headers: new Headers({
+ "content-type": `multipart/mixed; boundary=${BOUNDARY}`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (array buffers)",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyDefaultBoundary
+ .split("\r\n")
+ .map((line) => new TextEncoder().encode(line + "\r\n"))
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "content-type": `multipart/mixed`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, results);
+ }
+ );
+
+ itAsync(
+ "can handle node stream bodies (array buffers) with batched results",
+ (resolve, reject) => {
+ const stream = Readable.from(
+ bodyBatchedResults
+ .split("\r\n")
+ .map((line) => new TextEncoder().encode(line + "\r\n"))
+ );
+
+ const fetch = jest.fn(async () => ({
+ status: 200,
+ body: stream,
+ // if no boundary is specified, default to -
+ headers: new Headers({
+ "Content-Type": `multipart/mixed;boundary="graphql";deferSpec=20220824`,
+ }),
+ }));
+ const link = new HttpLink({
+ fetch: fetch as any,
+ });
+
+ const observable = execute(link, { query: sampleDeferredQuery });
+ matchesResults(resolve, reject, observable, batchedResults);
+ }
+ );
+});
diff --git a/src/link/http/createHttpLink.ts b/src/link/http/createHttpLink.ts
index 84aab27d809..9ed98411a09 100644
--- a/src/link/http/createHttpLink.ts
+++ b/src/link/http/createHttpLink.ts
@@ -3,10 +3,14 @@ import '../../utilities/globals';
import { visit, DefinitionNode, VariableDefinitionNode } from 'graphql';
import { ApolloLink } from '../core';
-import { Observable } from '../../utilities';
+import { Observable, hasDirectives } from '../../utilities';
import { serializeFetchParameter } from './serializeFetchParameter';
import { selectURI } from './selectURI';
-import { parseAndCheckHttpResponse } from './parseAndCheckHttpResponse';
+import {
+ handleError,
+ readMultipartBody,
+ readJsonBody
+} from './parseAndCheckHttpResponse';
import { checkFetcher } from './checkFetcher';
import {
selectHttpOptionsAndBodyInternal,
@@ -131,6 +135,11 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => {
options.method = 'GET';
}
+ // does not match custom directives beginning with @defer
+ if (hasDirectives(['defer'], operation.query)) {
+ options.headers.accept = "multipart/mixed; deferSpec=20220824, application/json";
+ }
+
if (options.method === 'GET') {
const { newURI, parseError } = rewriteURIForGET(chosenURI, body);
if (parseError) {
@@ -156,55 +165,15 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => {
currentFetch!(chosenURI, options)
.then(response => {
operation.setContext({ response });
- return response;
- })
- .then(parseAndCheckHttpResponse(operation))
- .then(result => {
- // we have data and can send it to back up the link chain
- observer.next(result);
- observer.complete();
- return result;
- })
- .catch(err => {
- // fetch was cancelled so it's already been cleaned up in the unsubscribe
- if (err.name === 'AbortError') return;
- // if it is a network error, BUT there is graphql result info
- // fire the next observer before calling error
- // this gives apollo-client (and react-apollo) the `graphqlErrors` and `networErrors`
- // to pass to UI
- // this should only happen if we *also* have data as part of the response key per
- // the spec
- if (err.result && err.result.errors && err.result.data) {
- // if we don't call next, the UI can only show networkError because AC didn't
- // get any graphqlErrors
- // this is graphql execution result info (i.e errors and possibly data)
- // this is because there is no formal spec how errors should translate to
- // http status codes. So an auth error (401) could have both data
- // from a public field, errors from a private field, and a status of 401
- // {
- // user { // this will have errors
- // firstName
- // }
- // products { // this is public so will have data
- // cost
- // }
- // }
- //
- // the result of above *could* look like this:
- // {
- // data: { products: [{ cost: "$10" }] },
- // errors: [{
- // message: 'your session has timed out',
- // path: []
- // }]
- // }
- // status code of above would be a 401
- // in the UI you want to show data where you can, errors as data where you can
- // and use correct http status codes
- observer.next(err.result);
+ const ctype = response.headers?.get('content-type');
+
+ if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
+ return readMultipartBody(response, observer);
+ } else {
+ return readJsonBody(response, operation, observer);
}
- observer.error(err);
- });
+ })
+ .catch(err => handleError(err, observer));
return () => {
// XXX support canceling this request
diff --git a/src/link/http/iterators/LICENSE b/src/link/http/iterators/LICENSE
new file mode 100644
index 00000000000..5d5774fdb5e
--- /dev/null
+++ b/src/link/http/iterators/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021-2022 Kevin Malakoff
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/link/http/iterators/async.ts b/src/link/http/iterators/async.ts
new file mode 100644
index 00000000000..ce26e925710
--- /dev/null
+++ b/src/link/http/iterators/async.ts
@@ -0,0 +1,18 @@
+/**
+ * Original source:
+ * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/async.ts
+ */
+
+export default function asyncIterator(
+ source: AsyncIterableIterator
+): AsyncIterableIterator {
+ const iterator = source[Symbol.asyncIterator]();
+ return {
+ next(): Promise> {
+ return iterator.next();
+ },
+ [Symbol.asyncIterator](): AsyncIterableIterator {
+ return this;
+ },
+ };
+}
diff --git a/src/link/http/iterators/nodeStream.ts b/src/link/http/iterators/nodeStream.ts
new file mode 100644
index 00000000000..c0e00664c4e
--- /dev/null
+++ b/src/link/http/iterators/nodeStream.ts
@@ -0,0 +1,94 @@
+/**
+ * Original source:
+ * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/nodeStream.ts
+ */
+
+import { Readable as NodeReadableStream } from "stream";
+import { canUseAsyncIteratorSymbol } from "../../../utilities";
+
+interface NodeStreamIterator {
+ next(): Promise>;
+ [Symbol.asyncIterator]?(): AsyncIterator;
+}
+
+export default function nodeStreamIterator(
+ stream: NodeReadableStream
+): AsyncIterableIterator {
+ let cleanup: (() => void) | null = null;
+ let error: Error | null = null;
+ let done = false;
+ const data: unknown[] = [];
+
+ const waiting: [
+ (
+ value:
+ | IteratorResult
+ | PromiseLike>
+ ) => void,
+ (reason?: any) => void
+ ][] = [];
+
+ function onData(chunk: any) {
+ if (error) return;
+ if (waiting.length) {
+ const shiftedArr = waiting.shift();
+ if (Array.isArray(shiftedArr) && shiftedArr[0]) {
+ return shiftedArr[0]({ value: chunk, done: false });
+ }
+ }
+ data.push(chunk);
+ }
+ function onError(err: Error) {
+ error = err;
+ const all = waiting.slice();
+ all.forEach(function (pair) {
+ pair[1](err);
+ });
+ !cleanup || cleanup();
+ }
+ function onEnd() {
+ done = true;
+ const all = waiting.slice();
+ all.forEach(function (pair) {
+ pair[0]({ value: undefined, done: true });
+ });
+ !cleanup || cleanup();
+ }
+
+ cleanup = function () {
+ cleanup = null;
+ stream.removeListener("data", onData);
+ stream.removeListener("error", onError);
+ stream.removeListener("end", onEnd);
+ stream.removeListener("finish", onEnd);
+ stream.removeListener("close", onEnd);
+ };
+ stream.on("data", onData);
+ stream.on("error", onError);
+ stream.on("end", onEnd);
+ stream.on("finish", onEnd);
+ stream.on("close", onEnd);
+
+ function getNext(): Promise> {
+ return new Promise(function (resolve, reject) {
+ if (error) return reject(error);
+ if (data.length) return resolve({ value: data.shift() as T, done: false });
+ if (done) return resolve({ value: undefined, done: true });
+ waiting.push([resolve, reject]);
+ });
+ }
+
+ const iterator: NodeStreamIterator = {
+ next(): Promise> {
+ return getNext();
+ },
+ };
+
+ if (canUseAsyncIteratorSymbol) {
+ iterator[Symbol.asyncIterator] = function (): AsyncIterator {
+ return this;
+ };
+ }
+
+ return iterator as AsyncIterableIterator;
+}
diff --git a/src/link/http/iterators/promise.ts b/src/link/http/iterators/promise.ts
new file mode 100644
index 00000000000..10668c547a8
--- /dev/null
+++ b/src/link/http/iterators/promise.ts
@@ -0,0 +1,43 @@
+/**
+ * Original source:
+ * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/promise.ts
+ */
+
+import { canUseAsyncIteratorSymbol } from "../../../utilities";
+
+interface PromiseIterator {
+ next(): Promise>;
+ [Symbol.asyncIterator]?(): AsyncIterator;
+}
+
+export default function promiseIterator(
+ promise: Promise
+): AsyncIterableIterator {
+ let resolved = false;
+
+ const iterator: PromiseIterator = {
+ next(): Promise> {
+ if (resolved)
+ return Promise.resolve({
+ value: undefined,
+ done: true,
+ });
+ resolved = true;
+ return new Promise(function (resolve, reject) {
+ promise
+ .then(function (value) {
+ resolve({ value: value as unknown as T, done: false });
+ })
+ .catch(reject);
+ });
+ },
+ };
+
+ if (canUseAsyncIteratorSymbol) {
+ iterator[Symbol.asyncIterator] = function (): AsyncIterator {
+ return this;
+ };
+ }
+
+ return iterator as AsyncIterableIterator;
+}
diff --git a/src/link/http/iterators/reader.ts b/src/link/http/iterators/reader.ts
new file mode 100644
index 00000000000..4fd684f9d71
--- /dev/null
+++ b/src/link/http/iterators/reader.ts
@@ -0,0 +1,29 @@
+/**
+ * Original source:
+ * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/reader.ts
+ */
+
+import { canUseAsyncIteratorSymbol } from "../../../utilities";
+
+interface ReaderIterator {
+ next(): Promise>;
+ [Symbol.asyncIterator]?(): AsyncIterator;
+}
+
+export default function readerIterator(
+ reader: ReadableStreamDefaultReader
+): AsyncIterableIterator {
+ const iterator: ReaderIterator = {
+ next() {
+ return reader.read();
+ },
+ };
+
+ if (canUseAsyncIteratorSymbol) {
+ iterator[Symbol.asyncIterator] = function (): AsyncIterator {
+ return this;
+ };
+ }
+
+ return iterator as AsyncIterableIterator;
+}
diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts
index 2f314fd0a5b..7d6b98b888b 100644
--- a/src/link/http/parseAndCheckHttpResponse.ts
+++ b/src/link/http/parseAndCheckHttpResponse.ts
@@ -1,5 +1,7 @@
-import { Operation } from '../core';
-import { throwServerError } from '../utils';
+import { responseIterator } from "./responseIterator";
+import { Operation } from "../core";
+import { throwServerError } from "../utils";
+import { Observer } from "../../utilities";
const { hasOwnProperty } = Object.prototype;
@@ -9,49 +11,194 @@ export type ServerParseError = Error & {
bodyText: string;
};
-export function parseAndCheckHttpResponse(
- operations: Operation | Operation[],
+export async function readMultipartBody>(
+ response: Response,
+ observer: Observer
) {
- return (response: Response) => response
- .text()
- .then(bodyText => {
- try {
- return JSON.parse(bodyText);
- } catch (err) {
- const parseError = err as ServerParseError;
- parseError.name = 'ServerParseError';
- parseError.response = response;
- parseError.statusCode = response.status;
- parseError.bodyText = bodyText;
- throw parseError;
+ if (TextDecoder === undefined) {
+ throw new Error(
+ "TextDecoder must be defined in the environment: please import a polyfill."
+ );
+ }
+ const decoder = new TextDecoder("utf-8");
+ const contentType = response.headers?.get('content-type');
+ const delimiter = "boundary=";
+
+ // parse boundary value and ignore any subsequent name/value pairs after ;
+ // https://www.rfc-editor.org/rfc/rfc9110.html#name-parameters
+ // e.g. multipart/mixed;boundary="graphql";deferSpec=20220824
+ // if no boundary is specified, default to -
+ const boundaryVal = contentType?.includes(delimiter)
+ ? contentType
+ ?.substring(contentType?.indexOf(delimiter) + delimiter.length)
+ .replace(/['"]/g, "")
+ .replace(/\;(.*)/gm, "")
+ .trim()
+ : "-";
+
+ let boundary = `--${boundaryVal}`;
+ let buffer = "";
+ const iterator = responseIterator(response);
+ let running = true;
+
+ while (running) {
+ const { value, done } = await iterator.next();
+ const chunk = typeof value === "string" ? value : decoder.decode(value);
+ running = !done;
+ buffer += chunk;
+ let bi = buffer.indexOf(boundary);
+
+ while (bi > -1) {
+ let message: string;
+ [message, buffer] = [
+ buffer.slice(0, bi),
+ buffer.slice(bi + boundary.length),
+ ];
+ if (message.trim()) {
+ const i = message.indexOf("\r\n\r\n");
+ const headers = parseHeaders(message.slice(0, i));
+ const contentType = headers["content-type"];
+ if (
+ contentType &&
+ contentType.toLowerCase().indexOf("application/json") === -1
+ ) {
+ throw new Error("Unsupported patch content type: application/json is required.");
+ }
+ const body = message.slice(i);
+
+ try {
+ const result = parseJsonBody(response, body.replace("\r\n", ""));
+ if (
+ Object.keys(result).length > 1 ||
+ "data" in result ||
+ "incremental" in result ||
+ "errors" in result
+ ) {
+ // for the last chunk with only `hasNext: false`,
+ // we don't need to call observer.next as there is no data/errors
+ observer.next?.(result);
+ }
+ } catch (err) {
+ handleError(err, observer);
+ }
}
+ bi = buffer.indexOf(boundary);
+ }
+ }
+ observer.complete?.();
+}
+
+export function parseHeaders(headerText: string): Record {
+ const headersInit: Record = {};
+ headerText.split("\n").forEach((line) => {
+ const i = line.indexOf(":");
+ if (i > -1) {
+ // normalize headers to lowercase
+ const name = line.slice(0, i).trim().toLowerCase();
+ const value = line.slice(i + 1).trim();
+ headersInit[name] = value;
+ }
+ });
+ return headersInit;
+}
+
+export function parseJsonBody(response: Response, bodyText: string): T {
+ try {
+ return JSON.parse(bodyText) as T;
+ } catch (err) {
+ const parseError = err as ServerParseError;
+ parseError.name = "ServerParseError";
+ parseError.response = response;
+ parseError.statusCode = response.status;
+ parseError.bodyText = bodyText;
+ throw parseError;
+ }
+}
+
+export function handleError(err: any, observer: Observer) {
+ if (err.name === "AbortError") return;
+ // if it is a network error, BUT there is graphql result info fire
+ // the next observer before calling error this gives apollo-client
+ // (and react-apollo) the `graphqlErrors` and `networErrors` to
+ // pass to UI this should only happen if we *also* have data as
+ // part of the response key per the spec
+ if (err.result && err.result.errors && err.result.data) {
+ // if we don't call next, the UI can only show networkError
+ // because AC didn't get any graphqlErrors this is graphql
+ // execution result info (i.e errors and possibly data) this is
+ // because there is no formal spec how errors should translate to
+ // http status codes. So an auth error (401) could have both data
+ // from a public field, errors from a private field, and a status
+ // of 401
+ // {
+ // user { // this will have errors
+ // firstName
+ // }
+ // products { // this is public so will have data
+ // cost
+ // }
+ // }
+ //
+ // the result of above *could* look like this:
+ // {
+ // data: { products: [{ cost: "$10" }] },
+ // errors: [{
+ // message: 'your session has timed out',
+ // path: []
+ // }]
+ // }
+ // status code of above would be a 401
+ // in the UI you want to show data where you can, errors as data where you can
+ // and use correct http status codes
+ observer.next?.(err.result);
+ }
+
+ observer.error?.(err);
+}
+
+export function readJsonBody>(
+ response: Response,
+ operation: Operation,
+ observer: Observer
+) {
+ parseAndCheckHttpResponse(operation)(response)
+ .then((result) => {
+ observer.next?.(result);
+ observer.complete?.();
})
- .then((result: any) => {
- if (response.status >= 300) {
- // Network error
- throwServerError(
- response,
- result,
- `Response not successful: Received status code ${response.status}`,
- );
- }
+ .catch((err) => handleError(err, observer));
+}
- if (
- !Array.isArray(result) &&
- !hasOwnProperty.call(result, 'data') &&
- !hasOwnProperty.call(result, 'errors')
- ) {
- // Data error
- throwServerError(
- response,
- result,
- `Server response was missing for query '${
- Array.isArray(operations)
- ? operations.map(op => op.operationName)
- : operations.operationName
- }'.`,
- );
- }
- return result;
- });
+export function parseAndCheckHttpResponse(operations: Operation | Operation[]) {
+ return (response: Response) =>
+ response
+ .text()
+ .then((bodyText) => parseJsonBody(response, bodyText))
+ .then((result: any) => {
+ if (response.status >= 300) {
+ // Network error
+ throwServerError(
+ response,
+ result,
+ `Response not successful: Received status code ${response.status}`
+ );
+ }
+ if (
+ !Array.isArray(result) &&
+ !hasOwnProperty.call(result, "data") &&
+ !hasOwnProperty.call(result, "errors")
+ ) {
+ // Data error
+ throwServerError(
+ response,
+ result,
+ `Server response was missing for query '${
+ Array.isArray(operations)
+ ? operations.map((op) => op.operationName)
+ : operations.operationName
+ }'.`
+ );
+ }
+ return result;
+ });
}
diff --git a/src/link/http/responseIterator.ts b/src/link/http/responseIterator.ts
new file mode 100644
index 00000000000..a0e565e464c
--- /dev/null
+++ b/src/link/http/responseIterator.ts
@@ -0,0 +1,47 @@
+/**
+ * Original source:
+ * https://github.com/kmalakoff/response-iterator/blob/master/src/index.ts
+ */
+
+import { Response as NodeResponse } from "node-fetch";
+import {
+ isAsyncIterableIterator,
+ isBlob,
+ isNodeResponse,
+ isNodeReadableStream,
+ isReadableStream,
+ isStreamableBlob,
+} from "../../utilities/common/responseIterator";
+
+import asyncIterator from "./iterators/async";
+import nodeStreamIterator from "./iterators/nodeStream";
+import promiseIterator from "./iterators/promise";
+import readerIterator from "./iterators/reader";
+
+export function responseIterator(
+ response: Response | NodeResponse
+): AsyncIterableIterator {
+ let body: unknown = response;
+
+ if (isNodeResponse(response)) body = response.body;
+
+ if (isAsyncIterableIterator(body)) return asyncIterator(body);
+
+ if (isReadableStream(body)) return readerIterator(body.getReader());
+
+ // this errors without casting to ReadableStream
+ // because Blob.stream() returns a NodeJS ReadableStream
+ if (isStreamableBlob(body)) {
+ return readerIterator(
+ (body.stream() as unknown as ReadableStream).getReader()
+ );
+ }
+
+ if (isBlob(body)) return promiseIterator(body.arrayBuffer());
+
+ if (isNodeReadableStream(body)) return nodeStreamIterator(body);
+
+ throw new Error(
+ "Unknown body type for responseIterator. Please pass a streamable response."
+ );
+}
diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx
index 0a7219d1fbd..a0704888388 100644
--- a/src/react/hooks/__tests__/useQuery.test.tsx
+++ b/src/react/hooks/__tests__/useQuery.test.tsx
@@ -17,7 +17,13 @@ import { InMemoryCache } from '../../../cache';
import { ApolloProvider } from '../../context';
import { Observable, Reference, concatPagination } from '../../../utilities';
import { ApolloLink } from '../../../link/core';
-import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing';
+import {
+ itAsync,
+ MockLink,
+ MockedProvider,
+ MockSubscriptionLink,
+ mockSingleLink,
+} from '../../../testing';
import { QueryResult } from "../../types/types";
import { useQuery } from '../useQuery';
import { useMutation } from '../useMutation';
@@ -4912,4 +4918,728 @@ describe('useQuery Hook', () => {
}).then(resolve, reject);
});
});
+
+ describe('defer', () => {
+ it('should handle deferred queries', async () => {
+ const query = gql`
+ {
+ greeting {
+ message
+ ... on Greeting @defer {
+ recipient {
+ name
+ }
+ }
+ }
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ },
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ },
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ incremental: [{
+ data: {
+ recipient: {
+ name: 'Alice',
+ __typename: 'Person',
+ },
+ __typename: 'Greeting',
+ },
+ path: ['greeting'],
+ }],
+ hasNext: false
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ recipient: {
+ name: 'Alice',
+ __typename: 'Person',
+ },
+ },
+ });
+ });
+
+ it('should handle deferred queries in lists', async () => {
+ const query = gql`
+ {
+ greetings {
+ message
+ ... on Greeting @defer {
+ recipient {
+ name
+ }
+ }
+ }
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ greetings: [
+ { message: 'Hello world', __typename: 'Greeting' },
+ { message: 'Hello again', __typename: 'Greeting' },
+ ],
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greetings: [
+ { message: 'Hello world', __typename: 'Greeting' },
+ { message: 'Hello again', __typename: 'Greeting' },
+ ],
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ incremental: [{
+ data: {
+ recipient: {
+ name: 'Alice',
+ __typename: 'Person',
+ },
+ __typename: 'Greeting',
+ },
+ path: ['greetings', 0],
+ }],
+ hasNext: true,
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greetings: [
+ {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ recipient: { name: 'Alice', __typename: 'Person' },
+ },
+ { message: 'Hello again', __typename: 'Greeting' },
+ ],
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ incremental: [{
+ data: {
+ recipient: {
+ name: 'Bob',
+ __typename: 'Person',
+ },
+ __typename: 'Greeting',
+ },
+ path: ['greetings', 1],
+ }],
+ hasNext: false
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greetings: [
+ {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ recipient: { name: 'Alice', __typename: 'Person' },
+ },
+ {
+ message: 'Hello again',
+ __typename: 'Greeting',
+ recipient: { name: 'Bob', __typename: 'Person' },
+ },
+ ],
+ });
+ });
+
+ it('should handle deferred queries in lists, merging arrays', async () => {
+ const query = gql`
+ query DeferVariation {
+ allProducts {
+ delivery {
+ ...MyFragment @defer
+ }
+ sku,
+ id
+ }
+ }
+ fragment MyFragment on DeliveryEstimates {
+ estimatedDelivery
+ fastestDelivery
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ allProducts: [
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates"
+ },
+ id: "apollo-federation",
+ sku: "federation"
+ },
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates"
+ },
+ id: "apollo-studio",
+ sku: "studio"
+ }
+ ]
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ allProducts: [
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates"
+ },
+ id: "apollo-federation",
+ sku: "federation"
+ },
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates"
+ },
+ id: "apollo-studio",
+ sku: "studio"
+ }
+ ]
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ hasNext: true,
+ incremental: [
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: [
+ "allProducts",
+ 0,
+ "delivery"
+ ]
+ },
+ {
+ data: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021",
+ },
+ path: [
+ "allProducts",
+ 1,
+ "delivery"
+ ]
+ },
+ ]
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ allProducts: [
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021"
+ },
+ id: "apollo-federation",
+ sku: "federation"
+ },
+ {
+ __typename: "Product",
+ delivery: {
+ __typename: "DeliveryEstimates",
+ estimatedDelivery: "6/25/2021",
+ fastestDelivery: "6/24/2021"
+ },
+ id: "apollo-studio",
+ sku: "studio"
+ }
+ ]
+ });
+ });
+
+ it('should handle deferred queries with fetch policy no-cache', async () => {
+ const query = gql`
+ {
+ greeting {
+ message
+ ... on Greeting @defer {
+ recipient {
+ name
+ }
+ }
+ }
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query, {fetchPolicy: 'no-cache'}),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ },
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ },
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ incremental: [{
+ data: {
+ recipient: {
+ name: 'Alice',
+ __typename: 'Person',
+ },
+ __typename: 'Greeting',
+ },
+ path: ['greeting'],
+ }],
+ hasNext: false
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ greeting: {
+ message: 'Hello world',
+ __typename: 'Greeting',
+ recipient: {
+ name: 'Alice',
+ __typename: 'Person',
+ },
+ },
+ });
+ });
+
+ it('should handle deferred queries with errors returned on the incremental batched result', async () => {
+ const query = gql`
+ query {
+ hero {
+ name
+ heroFriends {
+ id
+ name
+ ... @defer {
+ homeWorld
+ }
+ }
+ }
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ hero: {
+ name: "R2-D2",
+ heroFriends: [
+ {
+ id: "1000",
+ name: "Luke Skywalker"
+ },
+ {
+ id: "1003",
+ name: "Leia Organa"
+ }
+ ]
+ }
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ hero: {
+ heroFriends: [
+ {
+ id: '1000',
+ name: 'Luke Skywalker'
+ },
+ {
+ id: '1003',
+ name: 'Leia Organa'
+ },
+ ],
+ name: "R2-D2"
+ }
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ incremental: [
+ {
+ path: ["hero", "heroFriends", 0],
+ errors: [
+ new GraphQLError(
+ "homeWorld for character with ID 1000 could not be fetched.",
+ { path: ["hero", "heroFriends", 0, "homeWorld"] }
+ )
+ ],
+ data: {
+ "homeWorld": null,
+ }
+ },
+ {
+ path: ["hero", "heroFriends", 1],
+ data: {
+ "homeWorld": "Alderaan",
+ }
+ },
+ ],
+ "hasNext": false
+ }
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeInstanceOf(ApolloError);
+ expect(result.current.error!.message).toBe('homeWorld for character with ID 1000 could not be fetched.');
+
+ // since default error policy is "none", we do *not* return partial results
+ expect(result.current.data).toEqual({
+ hero: {
+ heroFriends: [
+ {
+ id: '1000',
+ name: 'Luke Skywalker'
+ },
+ {
+ id: '1003',
+ name: 'Leia Organa'
+ },
+ ],
+ name: "R2-D2"
+ }
+ });
+ });
+
+ it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => {
+ const query = gql`
+ query {
+ hero {
+ name
+ heroFriends {
+ id
+ name
+ ... @defer {
+ homeWorld
+ }
+ }
+ }
+ }
+ `;
+
+ const link = new MockSubscriptionLink();
+
+ const client = new ApolloClient({
+ link,
+ cache: new InMemoryCache(),
+ });
+
+ const { result, waitForNextUpdate } = renderHook(
+ () => useQuery(query, { errorPolicy: "all" }),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBe(undefined);
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ data: {
+ hero: {
+ name: "R2-D2",
+ heroFriends: [
+ {
+ id: "1000",
+ name: "Luke Skywalker"
+ },
+ {
+ id: "1003",
+ name: "Leia Organa"
+ }
+ ]
+ }
+ },
+ hasNext: true
+ },
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toEqual({
+ hero: {
+ heroFriends: [
+ {
+ id: '1000',
+ name: 'Luke Skywalker'
+ },
+ {
+ id: '1003',
+ name: 'Leia Organa'
+ },
+ ],
+ name: "R2-D2"
+ }
+ });
+
+ setTimeout(() => {
+ link.simulateResult({
+ result: {
+ extensions: {
+ thing1: 'foo',
+ thing2: 'bar',
+ },
+ incremental: [
+ {
+ path: ["hero", "heroFriends", 0],
+ errors: [
+ new GraphQLError(
+ "homeWorld for character with ID 1000 could not be fetched.",
+ { path: ["hero", "heroFriends", 0, "homeWorld"] }
+ )
+ ],
+ data: {
+ "homeWorld": null,
+ }
+ },
+ {
+ path: ["hero", "heroFriends", 1],
+ data: {
+ "homeWorld": "Alderaan",
+ }
+ },
+ ],
+ "hasNext": false
+ }
+ });
+ });
+
+ await waitForNextUpdate();
+ expect(result.current.loading).toBe(false);
+ // @ts-ignore
+ expect(result.current.label).toBe(undefined);
+ // @ts-ignore
+ expect(result.current.extensions).toBe(undefined);
+ expect(result.current.error).toBeInstanceOf(ApolloError);
+ expect(result.current.error!.message).toBe('homeWorld for character with ID 1000 could not be fetched.');
+
+ // since default error policy is "all", we *do* return partial results
+ expect(result.current.data).toEqual({
+ hero: {
+ heroFriends: [
+ {
+ // the only difference with the previous test
+ // is that homeWorld is populated since errorPolicy: all
+ // populates both partial data and error.graphQLErrors
+ homeWorld: null,
+ id: '1000',
+ name: 'Luke Skywalker'
+ },
+ {
+ // homeWorld is populated due to errorPolicy: all
+ homeWorld: "Alderaan",
+ id: '1003',
+ name: 'Leia Organa'
+ },
+ ],
+ name: "R2-D2"
+ }
+ });
+ });
+ });
});
diff --git a/src/testing/core/subscribeAndCount.ts b/src/testing/core/subscribeAndCount.ts
index eb1488015c6..a80f4ebe94c 100644
--- a/src/testing/core/subscribeAndCount.ts
+++ b/src/testing/core/subscribeAndCount.ts
@@ -1,28 +1,21 @@
-import { ObservableQuery, ApolloQueryResult, OperationVariables } from '../../core';
-import { ObservableSubscription, asyncMap } from '../../utilities';
+import { ObservableSubscription, asyncMap, Observable } from '../../utilities';
-export default function subscribeAndCount<
- TData,
- TVariables = OperationVariables,
->(
+export default function subscribeAndCount(
reject: (reason: any) => any,
- observable: ObservableQuery,
- cb: (handleCount: number, result: ApolloQueryResult) => any,
+ observable: Observable,
+ cb: (handleCount: number, result: TResult) => any,
): ObservableSubscription {
// Use a Promise queue to prevent callbacks from being run out of order.
let queue = Promise.resolve();
let handleCount = 0;
- const subscription = asyncMap(
- observable,
- (result: ApolloQueryResult) => {
- // All previous asynchronous callbacks must complete before cb can
- // be invoked with this result.
- return queue = queue.then(() => {
- return cb(++handleCount, result);
- }).catch(error);
- },
- ).subscribe({ error });
+ const subscription = asyncMap(observable, result => {
+ // All previous asynchronous callbacks must complete before cb can
+ // be invoked with this result.
+ return queue = queue.then(() => {
+ return cb(++handleCount, result);
+ }).catch(error);
+ }).subscribe({ error });
function error(e: any) {
subscription.unsubscribe();
diff --git a/src/utilities/common/canUse.ts b/src/utilities/common/canUse.ts
index 0453fbac425..453379b7ba7 100644
--- a/src/utilities/common/canUse.ts
+++ b/src/utilities/common/canUse.ts
@@ -10,6 +10,8 @@ export const canUseSymbol =
typeof Symbol === 'function' &&
typeof Symbol.for === 'function';
+export const canUseAsyncIteratorSymbol = canUseSymbol && Symbol.asyncIterator;
+
export const canUseDOM =
typeof maybe(() => window.document.createElement) === "function";
diff --git a/src/utilities/common/incrementalResult.ts b/src/utilities/common/incrementalResult.ts
new file mode 100644
index 00000000000..ddb6be317cc
--- /dev/null
+++ b/src/utilities/common/incrementalResult.ts
@@ -0,0 +1,5 @@
+import { ExecutionPatchIncrementalResult } from '../../link/core';
+
+export function isExecutionPatchIncrementalResult(value: any): value is ExecutionPatchIncrementalResult {
+ return !!(value as ExecutionPatchIncrementalResult).incremental;
+}
diff --git a/src/utilities/common/responseIterator.ts b/src/utilities/common/responseIterator.ts
new file mode 100644
index 00000000000..99a02096b94
--- /dev/null
+++ b/src/utilities/common/responseIterator.ts
@@ -0,0 +1,32 @@
+import { Response as NodeResponse } from "node-fetch";
+import { Readable as NodeReadableStream } from "stream";
+import { canUseAsyncIteratorSymbol } from "./canUse";
+
+export function isNodeResponse(value: any): value is NodeResponse {
+ return !!(value as NodeResponse).body;
+}
+
+export function isReadableStream(value: any): value is ReadableStream {
+ return !!(value as ReadableStream).getReader;
+}
+
+export function isAsyncIterableIterator(
+ value: any
+): value is AsyncIterableIterator {
+ return !!(
+ canUseAsyncIteratorSymbol &&
+ (value as AsyncIterableIterator)[Symbol.asyncIterator]
+ );
+}
+
+export function isStreamableBlob(value: any): value is Blob {
+ return !!(value as Blob).stream;
+}
+
+export function isBlob(value: any): value is Blob {
+ return !!(value as Blob).arrayBuffer;
+}
+
+export function isNodeReadableStream(value: any): value is NodeReadableStream {
+ return !!(value as NodeReadableStream).pipe;
+}
diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts
index 76c36b08083..4e335d261a7 100644
--- a/src/utilities/graphql/directives.ts
+++ b/src/utilities/graphql/directives.ts
@@ -12,6 +12,7 @@ import {
ValueNode,
ASTNode,
visit,
+ BREAK,
} from 'graphql';
export type DirectiveInfo = {
@@ -55,9 +56,21 @@ export function getDirectiveNames(root: ASTNode) {
}
export function hasDirectives(names: string[], root: ASTNode) {
- return getDirectiveNames(root).some(
- (name: string) => names.indexOf(name) > -1,
- );
+ const nameSet = new Set(names);
+ const nameCount = nameSet.size;
+
+ visit(root, {
+ Directive(node) {
+ if (nameSet.delete(node.name.value)) {
+ // We can abandon the traversal early as soon as we encounter any of the
+ // directives in the names array, since hasDirectives returns true when
+ // any (not necessarily all) of the named directives are found.
+ return BREAK;
+ }
+ },
+ });
+
+ return nameSet.size < nameCount;
}
export function hasClientExports(document: DocumentNode) {