Repository: FormidableLabs/urql
Pull request #722 (2020-04-20 14:20:16)

Add @urql/core/internal entrypoint for internal utilities

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 5de1c12f14c981531d0475cb6bff413d286a0e16

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/chore/core-internal-entry?filename=.changeset/rotten-ducks-dress.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A%22%40urql%2Fpreact%22%3A%20patch%0A%22urql%22%3A%20patch%0A%22%40urql%2Fsvelte%22%3A%20patch%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A%22%40urql%2Fexchange-multipart-fetch%22%3A%20patch%0A%22%40urql%2Fexchange-populate%22%3A%20patch%0A%22%40urql%2Fexchange-retry%22%3A%20patch%0A%22%40urql%2Fexchange-suspense%22%3A%20patch%0A---%0A%0AAdd%20%40urql%2Fcore%2Finternal%20entrypoint%20for%20internal%20utilities%0A)



Patch:
diff --git a/.changeset/wet-rabbits-tie.md b/.changeset/wet-rabbits-tie.md
new file mode 100644
index 00000000..c686e8fe
--- /dev/null
+++ b/.changeset/wet-rabbits-tie.md
@@ -0,0 +1,6 @@
+---
+'@urql/core': minor
+'@urql/exchange-multipart-fetch': patch
+---
+
+Add @urql/core/internal entrypoint for internally shared utilities and start sharing fetchExchange-related code.
diff --git a/exchanges/graphcache/tsconfig.json b/exchanges/graphcache/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/exchanges/graphcache/tsconfig.json
+++ b/exchanges/graphcache/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/exchanges/multipart-fetch/src/multipartFetchExchange.ts b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
index e8b872ce..188efcf7 100644
--- a/exchanges/multipart-fetch/src/multipartFetchExchange.ts
+++ b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
@@ -1,34 +1,27 @@
-import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql';
-import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
+import { filter, merge, mergeMap, pipe, share, takeUntil, onPush } from 'wonka';
 import { extractFiles } from 'extract-files';
+import { Exchange } from '@urql/core';
 
 import {
-  ExchangeInput,
-  Exchange,
-  Operation,
-  OperationResult,
-  makeResult,
-  makeErrorResult,
-} from '@urql/core';
-
-interface Body {
-  query: string;
-  variables: void | object;
-  operationName?: string;
-}
-
-const isOperationFetchable = (operation: Operation) =>
-  operation.operationName === 'query' || operation.operationName === 'mutation';
+  makeFetchBody,
+  makeFetchURL,
+  makeFetchOptions,
+  makeFetchSource,
+} from '@urql/core/internal';
 
 export const multipartFetchExchange: Exchange = ({
   forward,
   dispatchDebug,
 }) => ops$ => {
   const sharedOps$ = share(ops$);
-
   const fetchResults$ = pipe(
     sharedOps$,
-    filter(isOperationFetchable),
+    filter(operation => {
+      return (
+        operation.operationName === 'query' ||
+        operation.operationName === 'mutation'
+      );
+    }),
     mergeMap(operation => {
       const teardown$ = pipe(
         sharedOps$,
@@ -37,219 +30,85 @@ export const multipartFetchExchange: Exchange = ({
         )
       );
 
-      return pipe(
-        createFetchSource(
-          operation,
-          operation.operationName === 'query' &&
-            !!operation.context.preferGetMethod,
-          dispatchDebug
-        ),
-        takeUntil(teardown$)
-      );
-    })
-  );
-
-  const forward$ = pipe(
-    sharedOps$,
-    filter(op => !isOperationFetchable(op)),
-    forward
-  );
-
-  return merge([fetchResults$, forward$]);
-};
-
-const getOperationName = (query: DocumentNode): string | null => {
-  const node = query.definitions.find(
-    (node: any): node is OperationDefinitionNode => {
-      return node.kind === Kind.OPERATION_DEFINITION && node.name;
-    }
-  );
-
-  return node && node.name ? node.name.value : null;
-};
-
-const createFetchSource = (
-  operation: Operation,
-  shouldUseGet: boolean,
-  dispatchDebug: ExchangeInput['dispatchDebug']
-) => {
-  if (
-    process.env.NODE_ENV !== 'production' &&
-    operation.operationName === 'subscription'
-  ) {
-    throw new Error(
-      'Received a subscription operation in the httpExchange. You are probably trying to create a subscription. Have you added a subscriptionExchange?'
-    );
-  }
-
-  return make<OperationResult>(({ next, complete }) => {
-    const abortController =
-      typeof AbortController !== 'undefined'
-        ? new AbortController()
-        : undefined;
-
-    const { context } = operation;
-    // Spreading operation.variables here in case someone made a variables with Object.create(null).
-    const { files, clone } = extractFiles({ ...operation.variables });
-
-    const extraOptions =
-      typeof context.fetchOptions === 'function'
-        ? context.fetchOptions()
-        : context.fetchOptions || {};
-
-    const operationName = getOperationName(operation.query);
-
-    const body: Body = {
-      query: print(operation.query),
-      variables: operation.variables,
-    };
-
-    if (operationName !== null) {
-      body.operationName = operationName;
-    }
-
-    const fetchOptions = {
-      ...extraOptions,
-      method: shouldUseGet ? 'GET' : 'POST',
-      headers: {
-        'content-type': 'application/json',
-        ...extraOptions.headers,
-      },
-      signal:
-        abortController !== undefined ? abortController.signal : undefined,
-    };
-
-    if (!!files.size) {
-      fetchOptions.body = new FormData();
-      fetchOptions.method = 'POST';
-      // Make fetch auto-append this for correctness
-      delete fetchOptions.headers['content-type'];
-
-      fetchOptions.body.append(
-        'operations',
-        JSON.stringify({
-          ...body,
-          variables: clone,
-        })
-      );
-
-      const map = {};
-      let i = 0;
-      files.forEach(paths => {
-        map[++i] = paths.map(path => `variables.${path}`);
+      // Spreading operation.variables here in case someone made a variables with Object.create(null).
+      const { files, clone: variables } = extractFiles({
+        ...operation.variables,
       });
-      fetchOptions.body.append('map', JSON.stringify(map));
-
-      i = 0;
-      files.forEach((_, file) => {
-        (fetchOptions.body as FormData).append(`${++i}`, file, file.name);
-      });
-    } else if (shouldUseGet) {
-      operation.context.url = convertToGet(operation.context.url, body);
-    } else {
-      fetchOptions.body = JSON.stringify(body);
-    }
-
-    let ended = false;
-
-    Promise.resolve()
-      .then(() =>
-        ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug)
-      )
-      .then((result: OperationResult | undefined) => {
-        if (!ended) {
-          ended = true;
-          if (result) next(result);
-          complete();
+      const body = makeFetchBody({ query: operation.query, variables });
+
+      let url: string;
+      let fetchOptions: RequestInit;
+      if (files.size) {
+        url = makeFetchURL(operation);
+        fetchOptions = makeFetchOptions(operation);
+        if (fetchOptions.headers!['content-type'] === 'application/json') {
+          delete fetchOptions.headers!['content-type'];
         }
-      });
 
-    return () => {
-      ended = true;
-      if (abortController !== undefined) {
-        abortController.abort();
-      }
-    };
-  });
-};
+        fetchOptions.method = 'POST';
+        fetchOptions.body = new FormData();
+        fetchOptions.body.append('operations', JSON.stringify(body));
 
-const executeFetch = (
-  operation: Operation,
-  opts: RequestInit,
-  dispatchDebug: ExchangeInput['dispatchDebug']
-): Promise<OperationResult> => {
-  const { url, fetch: fetcher } = operation.context;
-  let statusNotOk = false;
-  let response: Response;
+        const map = {};
+        let i = 0;
+        files.forEach(paths => {
+          map[++i] = paths.map(path => `variables.${path}`);
+        });
 
-  dispatchDebug({
-    type: 'fetchRequest',
-    message: 'A fetch request is being executed.',
-    operation,
-    data: {
-      url,
-      fetchOptions: opts,
-    },
-  });
+        fetchOptions.body.append('map', JSON.stringify(map));
 
-  return (fetcher || fetch)(url, opts)
-    .then((res: Response) => {
-      response = res;
-      statusNotOk =
-        res.status < 200 ||
-        res.status >= (opts.redirect === 'manual' ? 400 : 300);
-      return res.json();
-    })
-    .then((result: any) => {
-      if (!('data' in result) && !('errors' in result)) {
-        throw new Error('No Content');
+        i = 0;
+        files.forEach((_, file) => {
+          (fetchOptions.body as FormData).append(`${++i}`, file, file.name);
+        });
+      } else {
+        fetchOptions = makeFetchOptions(operation, body);
+        url = makeFetchURL(operation, body);
       }
 
       dispatchDebug({
-        type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess',
-        message: `A ${
-          result.errors ? 'failed' : 'successful'
-        } fetch response has been returned.`,
+        type: 'fetchRequest',
+        message: 'A fetch request is being executed.',
         operation,
         data: {
           url,
-          fetchOptions: opts,
-          value: result,
+          fetchOptions,
         },
       });
 
-      return makeResult(operation, result, response);
+      return pipe(
+        makeFetchSource(operation, url, fetchOptions),
+        takeUntil(teardown$),
+        onPush(result => {
+          const error = !result.data ? result.error : undefined;
+
+          dispatchDebug({
+            type: error ? 'fetchError' : 'fetchSuccess',
+            message: `A ${
+              error ? 'failed' : 'successful'
+            } fetch response has been returned.`,
+            operation,
+            data: {
+              url,
+              fetchOptions,
+              value: error || result,
+            },
+          });
+        })
+      );
     })
-    .catch((error: Error) => {
-      if (error.name !== 'AbortError') {
-        dispatchDebug({
-          type: 'fetchError',
-          message: error.name,
-          operation,
-          data: {
-            url,
-            fetchOptions: opts,
-            value: error,
-          },
-        });
-
-        return makeErrorResult(
-          operation,
-          statusNotOk ? new Error(response.statusText) : error,
-          response
-        );
-      }
-    });
-};
-
-export const convertToGet = (uri: string, body: Body): string => {
-  const queryParams: string[] = [`query=${encodeURIComponent(body.query)}`];
+  );
 
-  if (body.variables) {
-    queryParams.push(
-      `variables=${encodeURIComponent(JSON.stringify(body.variables))}`
-    );
-  }
+  const forward$ = pipe(
+    sharedOps$,
+    filter(operation => {
+      return (
+        operation.operationName !== 'query' &&
+        operation.operationName !== 'mutation'
+      );
+    }),
+    forward
+  );
 
-  return uri + '?' + queryParams.join('&');
+  return merge([fetchResults$, forward$]);
 };
diff --git a/exchanges/multipart-fetch/tsconfig.json b/exchanges/multipart-fetch/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/exchanges/multipart-fetch/tsconfig.json
+++ b/exchanges/multipart-fetch/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/exchanges/populate/tsconfig.json b/exchanges/populate/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/exchanges/populate/tsconfig.json
+++ b/exchanges/populate/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/exchanges/retry/tsconfig.json b/exchanges/retry/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/exchanges/retry/tsconfig.json
+++ b/exchanges/retry/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/exchanges/suspense/tsconfig.json b/exchanges/suspense/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/exchanges/suspense/tsconfig.json
+++ b/exchanges/suspense/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/packages/core/internal/package.json b/packages/core/internal/package.json
new file mode 100644
index 00000000..a388e9cd
--- /dev/null
+++ b/packages/core/internal/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "urql-core-internal",
+  "private": true,
+  "main": "../dist/urql-core-internal.js",
+  "module": "../dist/urql-core-internal.mjs",
+  "types": "../dist/types/internal/index.d.ts",
+  "source": "../src/internal/index.ts",
+  "dependencies": {
+    "wonka": "^4.0.9"
+  }
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index ea033a70..22c4f8a1 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -28,6 +28,12 @@
       "require": "./dist/urql-core.js",
       "types": "./dist/types/index.d.ts",
       "source": "./src/index.ts"
+    },
+    "./internal": {
+      "import": "./dist/urql-core-internal.mjs",
+      "require": "./dist/urql-core-internal.js",
+      "types": "./dist/types/internal/index.d.ts",
+      "source": "./src/internal/index.ts"
     }
   },
   "files": [
diff --git a/packages/core/src/exchanges/fetch.ts b/packages/core/src/exchanges/fetch.ts
index e0181ce1..aef6e4bd 100755
--- a/packages/core/src/exchanges/fetch.ts
+++ b/packages/core/src/exchanges/fetch.ts
@@ -1,14 +1,13 @@
 /* eslint-disable @typescript-eslint/no-use-before-define */
-import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql';
-import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
-import { Exchange, Operation, OperationResult, ExchangeInput } from '../types';
-import { makeResult, makeErrorResult } from '../utils';
+import { filter, merge, mergeMap, pipe, share, takeUntil, onPush } from 'wonka';
 
-interface Body {
-  query: string;
-  variables: void | object;
-  operationName?: string;
-}
+import { Exchange } from '../types';
+import {
+  makeFetchBody,
+  makeFetchURL,
+  makeFetchOptions,
+  makeFetchSource,
+} from '../internal';
 
 /** A default exchange for fetching GraphQL requests. */
 export const fetchExchange: Exchange = ({ forward, dispatchDebug }) => {
@@ -29,14 +28,39 @@ export const fetchExchange: Exchange = ({ forward, dispatchDebug }) => {
           filter(op => op.operationName === 'teardown' && op.key === key)
         );
 
+        const body = makeFetchBody(operation);
+        const url = makeFetchURL(operation, body);
+        const fetchOptions = makeFetchOptions(operation, body);
+
+        dispatchDebug({
+          type: 'fetchRequest',
+          message: 'A fetch request is being executed.',
+          operation,
+          data: {
+            url,
+            fetchOptions,
+          },
+        });
+
         return pipe(
-          createFetchSource(
-            operation,
-            operation.operationName === 'query' &&
-              !!operation.context.preferGetMethod,
-            dispatchDebug
-          ),
-          takeUntil(teardown$)
+          makeFetchSource(operation, url, fetchOptions),
+          takeUntil(teardown$),
+          onPush(result => {
+            const error = !result.data ? result.error : undefined;
+
+            dispatchDebug({
+              type: error ? 'fetchError' : 'fetchSuccess',
+              message: `A ${
+                error ? 'failed' : 'successful'
+              } fetch response has been returned.`,
+              operation,
+              data: {
+                url,
+                fetchOptions,
+                value: error || result,
+              },
+            });
+          })
         );
       })
     );
@@ -55,171 +79,3 @@ export const fetchExchange: Exchange = ({ forward, dispatchDebug }) => {
     return merge([fetchResults$, forward$]);
   };
 };
-
-const getOperationName = (query: DocumentNode): string | null => {
-  const node = query.definitions.find(
-    (node: any): node is OperationDefinitionNode => {
-      return node.kind === Kind.OPERATION_DEFINITION && node.name;
-    }
-  );
-
-  return node ? node.name!.value : null;
-};
-
-const createFetchSource = (
-  operation: Operation,
-  shouldUseGet: boolean,
-  dispatchDebug: ExchangeInput['dispatchDebug']
-) => {
-  if (
-    process.env.NODE_ENV !== 'production' &&
-    operation.operationName === 'subscription'
-  ) {
-    throw new Error(
-      'Received a subscription operation in the httpExchange. You are probably trying to create a subscription. Have you added a subscriptionExchange?'
-    );
-  }
-
-  return make<OperationResult>(({ next, complete }) => {
-    const abortController =
-      typeof AbortController !== 'undefined'
-        ? new AbortController()
-        : undefined;
-
-    const { context } = operation;
-
-    const extraOptions =
-      typeof context.fetchOptions === 'function'
-        ? context.fetchOptions()
-        : context.fetchOptions || {};
-
-    const operationName = getOperationName(operation.query);
-
-    const body: Body = {
-      query: print(operation.query),
-      variables: operation.variables,
-    };
-
-    if (operationName !== null) {
-      body.operationName = operationName;
-    }
-
-    const fetchOptions = {
-      ...extraOptions,
-      body: shouldUseGet ? undefined : JSON.stringify(body),
-      method: shouldUseGet ? 'GET' : 'POST',
-      headers: {
-        'content-type': 'application/json',
-        ...extraOptions.headers,
-      },
-      signal:
-        abortController !== undefined ? abortController.signal : undefined,
-    };
-
-    if (shouldUseGet) {
-      operation.context.url = convertToGet(operation.context.url, body);
-    }
-
-    let ended = false;
-
-    Promise.resolve()
-      .then(() =>
-        ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug)
-      )
-      .then((result: OperationResult | undefined) => {
-        if (!ended) {
-          ended = true;
-          if (result) next(result);
-          complete();
-        }
-      });
-
-    return () => {
-      ended = true;
-      if (abortController !== undefined) {
-        abortController.abort();
-      }
-    };
-  });
-};
-
-const executeFetch = (
-  operation: Operation,
-  opts: RequestInit,
-  dispatchDebug: ExchangeInput['dispatchDebug']
-): Promise<OperationResult> => {
-  const { url, fetch: fetcher } = operation.context;
-  let statusNotOk = false;
-  let response: Response;
-
-  dispatchDebug({
-    type: 'fetchRequest',
-    message: 'A fetch request is being executed.',
-    operation,
-    data: {
-      url,
-      fetchOptions: opts,
-    },
-  });
-
-  return (fetcher || fetch)(url, opts)
-    .then((res: Response) => {
-      response = res;
-      statusNotOk =
-        res.status < 200 ||
-        res.status >= (opts.redirect === 'manual' ? 400 : 300);
-      return res.json();
-    })
-    .then((result: any) => {
-      if (!('data' in result) && !('errors' in result)) {
-        throw new Error('No Content');
-      }
-
-      dispatchDebug({
-        type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess',
-        message: `A ${
-          result.errors ? 'failed' : 'successful'
-        } fetch response has been returned.`,
-        operation,
-        data: {
-          url,
-          fetchOptions: opts,
-          value: result,
-        },
-      });
-
-      return makeResult(operation, result, response);
-    })
-    .catch((error: Error) => {
-      if (error.name !== 'AbortError') {
-        dispatchDebug({
-          type: 'fetchError',
-          message: error.name,
-          operation,
-          data: {
-            url,
-            fetchOptions: opts,
-            value: error,
-          },
-        });
-
-        return makeErrorResult(
-          operation,
-          statusNotOk ? new Error(response.statusText) : error,
-          response
-        );
-      }
-    });
-};
-
-export const convertToGet = (uri: string, body: Body): string => {
-  const queryParams: string[] = [`query=${encodeURIComponent(body.query)}`];
-
-  if (body.variables) {
-    queryParams.push(
-      `variables=${encodeURIComponent(JSON.stringify(body.variables))}`
-    );
-  }
-
-  return uri + '?' + queryParams.join('&');
-};
diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts
new file mode 100644
index 00000000..646f943a
--- /dev/null
+++ b/packages/core/src/internal/fetchOptions.ts
@@ -0,0 +1,83 @@
+import { Kind, print, DocumentNode } from 'graphql';
+
+import { stringifyVariables } from '../utils';
+import { Operation } from '../types';
+
+export interface FetchBody {
+  query: string;
+  operationName: string | undefined;
+  variables: undefined | Record<string, any>;
+  extensions: undefined | Record<string, any>;
+}
+
+const getOperationName = (query: DocumentNode): string | undefined => {
+  for (let i = 0, l = query.definitions.length; i < l; i++) {
+    const node = query.definitions[i];
+    if (node.kind === Kind.OPERATION_DEFINITION && node.name) {
+      return node.name.value;
+    }
+  }
+};
+
+const shouldUseGet = (operation: Operation): boolean => {
+  return (
+    operation.operationName === 'query' && !!operation.context.preferGetMethod
+  );
+};
+
+export const makeFetchBody = (request: {
+  query: DocumentNode;
+  variables?: object;
+}): FetchBody => ({
+  query: print(request.query),
+  operationName: getOperationName(request.query),
+  variables: request.variables || undefined,
+  extensions: undefined,
+});
+
+export const makeFetchURL = (
+  operation: Operation,
+  body?: FetchBody
+): string => {
+  const useGETMethod = shouldUseGet(operation);
+  let url = operation.context.url;
+  if (!useGETMethod || !body) return url;
+
+  url += `?query=${encodeURIComponent(body.query)}`;
+
+  if (body.variables) {
+    url += `&variables=${encodeURIComponent(
+      stringifyVariables(body.variables)
+    )}`;
+  }
+
+  if (body.extensions) {
+    url += `&extensions=${encodeURIComponent(
+      stringifyVariables(body.extensions)
+    )}`;
+  }
+
+  return url;
+};
+
+export const makeFetchOptions = (
+  operation: Operation,
+  body?: FetchBody
+): RequestInit => {
+  const useGETMethod = shouldUseGet(operation);
+
+  const extraOptions =
+    typeof operation.context.fetchOptions === 'function'
+      ? operation.context.fetchOptions()
+      : operation.context.fetchOptions || {};
+
+  return {
+    ...extraOptions,
+    body: !useGETMethod && body ? JSON.stringify(body) : undefined,
+    method: useGETMethod ? 'GET' : 'POST',
+    headers: {
+      'content-type': 'application/json',
+      ...extraOptions.headers,
+    },
+  };
+};
diff --git a/packages/core/src/internal/fetchSource.ts b/packages/core/src/internal/fetchSource.ts
new file mode 100644
index 00000000..1903b73b
--- /dev/null
+++ b/packages/core/src/internal/fetchSource.ts
@@ -0,0 +1,77 @@
+import { Operation, OperationResult } from '../types';
+import { makeResult, makeErrorResult } from '../utils';
+import { make } from 'wonka';
+
+const executeFetch = (
+  operation: Operation,
+  url: string,
+  fetchOptions: RequestInit
+): Promise<OperationResult> => {
+  const fetcher = operation.context.fetch;
+
+  let statusNotOk = false;
+  let response: Response;
+
+  return (fetcher || fetch)(url, fetchOptions)
+    .then((res: Response) => {
+      response = res;
+      statusNotOk =
+        res.status < 200 ||
+        res.status >= (fetchOptions.redirect === 'manual' ? 400 : 300);
+      return res.json();
+    })
+    .then((result: any) => {
+      if (!('data' in result) && !('errors' in result)) {
+        throw new Error('No Content');
+      }
+
+      return makeResult(operation, result, response);
+    })
+    .catch((error: Error) => {
+      if (error.name !== 'AbortError') {
+        return makeErrorResult(
+          operation,
+          statusNotOk ? new Error(response.statusText) : error,
+          response
+        );
+      }
+    }) as Promise<OperationResult>;
+};
+
+export const makeFetchSource = (
+  operation: Operation,
+  url: string,
+  fetchOptions: RequestInit
+) => {
+  return make<OperationResult>(({ next, complete }) => {
+    const abortController =
+      typeof AbortController !== 'undefined' ? new AbortController() : null;
+
+    let ended = false;
+
+    Promise.resolve()
+      .then(() => {
+        if (ended) {
+          return;
+        } else if (abortController) {
+          fetchOptions.signal = abortController.signal;
+        }
+
+        return executeFetch(operation, url, fetchOptions);
+      })
+      .then((result: OperationResult | undefined) => {
+        if (!ended) {
+          ended = true;
+          if (result) next(result);
+          complete();
+        }
+      });
+
+    return () => {
+      ended = true;
+      if (abortController) {
+        abortController.abort();
+      }
+    };
+  });
+};
diff --git a/packages/core/src/internal/index.ts b/packages/core/src/internal/index.ts
new file mode 100644
index 00000000..28bd37ea
--- /dev/null
+++ b/packages/core/src/internal/index.ts
@@ -0,0 +1,2 @@
+export * from './fetchOptions';
+export * from './fetchSource';
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index ed8a851d..b690cf58 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -42,6 +42,7 @@ export interface OperationDebugMeta {
 export interface OperationContext {
   [key: string]: any;
   additionalTypenames?: string[];
+  fetch?: typeof fetch;
   fetchOptions?: RequestInit | (() => RequestInit);
   requestPolicy: RequestPolicy;
   url: string;
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/packages/preact-urql/tsconfig.json b/packages/preact-urql/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/packages/preact-urql/tsconfig.json
+++ b/packages/preact-urql/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/packages/react-urql/tsconfig.json b/packages/react-urql/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/packages/react-urql/tsconfig.json
+++ b/packages/react-urql/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/packages/svelte-urql/tsconfig.json b/packages/svelte-urql/tsconfig.json
index cd80aea8..5797ce61 100644
--- a/packages/svelte-urql/tsconfig.json
+++ b/packages/svelte-urql/tsconfig.json
@@ -6,6 +6,7 @@
     "paths": {
       "urql": ["../../node_modules/urql/src"],
       "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
       "@urql/*": ["../../node_modules/@urql/*/src"]
     }
   }
diff --git a/scripts/jest/preset.js b/scripts/jest/preset.js
index e20a2f7c..34e22cfe 100644
--- a/scripts/jest/preset.js
+++ b/scripts/jest/preset.js
@@ -9,6 +9,7 @@ module.exports = {
   moduleNameMapper: {
     "^urql$": "<rootDir>/../../node_modules/urql/src",
     "^(.*-urql)$": "<rootDir>/../../node_modules/$1/src",
+    "^@urql/(.*)/(.*)$": "<rootDir>/../../node_modules/@urql/$1/src/$2",
     "^@urql/(.*)$": "<rootDir>/../../node_modules/@urql/$1/src",
   },
   watchPlugins: ['jest-watch-yarn-workspaces'],
diff --git a/tsconfig.json b/tsconfig.json
index b5b2fabc..41d2690f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,6 +5,7 @@
       "urql": ["packages/react-urql/src"],
       "*-urql": ["packages/*-urql/src"],
       "@urql/exchange-*": ["exchanges/*/src"],
+      "@urql/core/*": ["packages/core/src/*"],
       "@urql/*": ["packages/*-urql/src", "packages/*/src"]
     },
     "esModuleInterop": true,


Test patch:
diff --git a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
index 2dc65289..07c048cc 100644
--- a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
+++ b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
@@ -409,7 +409,7 @@ Object {
 }
 `;
 
-exports[`on success returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\\\n\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"operationName\\":\\"getUser\\"}"`;
+exports[`on success returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\\\n\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
 
 exports[`on success uses a file when given 1`] = `
 Object {
diff --git a/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts b/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
index 3b188b68..1b24d090 100644
--- a/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
+++ b/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
@@ -1,9 +1,8 @@
 import { Client, OperationResult, OperationType } from '@urql/core';
 import { empty, fromValue, pipe, Source, subscribe, toPromise } from 'wonka';
-import gql from 'graphql-tag';
-import { print } from 'graphql';
 
-import { multipartFetchExchange, convertToGet } from './multipartFetchExchange';
+import { multipartFetchExchange } from './multipartFetchExchange';
+
 import {
   uploadOperation,
   queryOperation,
@@ -215,39 +214,3 @@ describe('on teardown', () => {
     expect(abort).toHaveBeenCalledTimes(0);
   });
 });
-
-describe('convert for GET', () => {
-  it('should do a basic conversion', () => {
-    const query = `query ($id: ID!) { node(id: $id) { id } }`;
-    const variables = { id: 2 };
-    expect(convertToGet('http://localhost:3000', { query, variables })).toBe(
-      `http://localhost:3000?query=${encodeURIComponent(
-        query
-      )}&variables=${encodeURIComponent(JSON.stringify(variables))}`
-    );
-  });
-
-  it('should do a basic conversion with fragments', () => {
-    const nodeFragment = gql`
-      fragment nodeFragment on Node {
-        id
-      }
-    `;
-
-    const variables = { id: 2 };
-    const query = print(gql`
-      query($id: ID!) {
-        node(id: $id) {
-          ...nodeFragment
-        }
-      }
-      ${nodeFragment}
-    `);
-
-    expect(convertToGet('http://localhost:3000', { query, variables })).toBe(
-      `http://localhost:3000?query=${encodeURIComponent(
-        query
-      )}&variables=${encodeURIComponent(JSON.stringify(variables))}`
-    );
-  });
-});
diff --git a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
index e71a5eb2..afac1ebe 100644
--- a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -409,4 +409,4 @@ Object {
 }
 `;
 
-exports[`on success returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\\\n\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"operationName\\":\\"getUser\\"}"`;
+exports[`on success returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\\\n\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
diff --git a/packages/core/src/exchanges/fetch.test.ts b/packages/core/src/exchanges/fetch.test.ts
index d4e2e6f7..b965c844 100755
--- a/packages/core/src/exchanges/fetch.test.ts
+++ b/packages/core/src/exchanges/fetch.test.ts
@@ -1,11 +1,9 @@
 import { empty, fromValue, pipe, Source, subscribe, toPromise } from 'wonka';
-import gql from 'graphql-tag';
-import { print } from 'graphql';
 
 import { Client } from '../client';
 import { queryOperation } from '../test-utils';
 import { OperationResult, OperationType } from '../types';
-import { fetchExchange, convertToGet } from './fetch';
+import { fetchExchange } from './fetch';
 
 const fetch = (global as any).fetch as jest.Mock;
 const abort = jest.fn();
@@ -174,39 +172,3 @@ describe('on teardown', () => {
     expect(abort).toHaveBeenCalledTimes(0);
   });
 });
-
-describe('convert for GET', () => {
-  it('should do a basic conversion', () => {
-    const query = `query ($id: ID!) { node(id: $id) { id } }`;
-    const variables = { id: 2 };
-    expect(convertToGet('http://localhost:3000', { query, variables })).toBe(
-      `http://localhost:3000?query=${encodeURIComponent(
-        query
-      )}&variables=${encodeURIComponent(JSON.stringify(variables))}`
-    );
-  });
-
-  it('should do a basic conversion with fragments', () => {
-    const nodeFragment = gql`
-      fragment nodeFragment on Node {
-        id
-      }
-    `;
-
-    const variables = { id: 2 };
-    const query = print(gql`
-      query($id: ID!) {
-        node(id: $id) {
-          ...nodeFragment
-        }
-      }
-      ${nodeFragment}
-    `);
-
-    expect(convertToGet('http://localhost:3000', { query, variables })).toBe(
-      `http://localhost:3000?query=${encodeURIComponent(
-        query
-      )}&variables=${encodeURIComponent(JSON.stringify(variables))}`
-    );
-  });
-});
diff --git a/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap b/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
new file mode 100644
index 00000000..fc902b59
--- /dev/null
+++ b/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
@@ -0,0 +1,670 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on error ignores the error when a result is available 1`] = `
+Object {
+  "data": undefined,
+  "error": [CombinedError: [Network] ],
+  "extensions": undefined,
+  "operation": Object {
+    "context": Object {
+      "fetchOptions": Object {
+        "method": "POST",
+      },
+      "requestPolicy": "cache-first",
+      "url": "http://localhost:3000/graphql",
+    },
+    "key": 2,
+    "operationName": "query",
+    "query": Object {
+      "definitions": Array [
+        Object {
+          "directives": Array [],
+          "kind": "OperationDefinition",
+          "name": Object {
+            "kind": "Name",
+            "value": "getUser",
+          },
+          "operation": "query",
+          "selectionSet": Object {
+            "kind": "SelectionSet",
+            "selections": Array [
+              Object {
+                "alias": undefined,
+                "arguments": Array [
+                  Object {
+                    "kind": "Argument",
+                    "name": Object {
+                      "kind": "Name",
+                      "value": "name",
+                    },
+                    "value": Object {
+                      "kind": "Variable",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "name",
+                      },
+                    },
+                  },
+                ],
+                "directives": Array [],
+                "kind": "Field",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "user",
+                },
+                "selectionSet": Object {
+                  "kind": "SelectionSet",
+                  "selections": Array [
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "id",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "firstName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "lastName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                  ],
+                },
+              },
+            ],
+          },
+          "variableDefinitions": Array [
+            Object {
+              "defaultValue": undefined,
+              "directives": Array [],
+              "kind": "VariableDefinition",
+              "type": Object {
+                "kind": "NamedType",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "String",
+                },
+              },
+              "variable": Object {
+                "kind": "Variable",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "name",
+                },
+              },
+            },
+          ],
+        },
+      ],
+      "kind": "Document",
+      "loc": Object {
+        "end": 124,
+        "start": 0,
+      },
+    },
+    "variables": Object {
+      "name": "Clara",
+    },
+  },
+}
+`;
+
+exports[`on error returns error data 1`] = `
+Object {
+  "data": undefined,
+  "error": [CombinedError: [Network] ],
+  "extensions": undefined,
+  "operation": Object {
+    "context": Object {
+      "fetchOptions": Object {
+        "method": "POST",
+      },
+      "requestPolicy": "cache-first",
+      "url": "http://localhost:3000/graphql",
+    },
+    "key": 2,
+    "operationName": "query",
+    "query": Object {
+      "definitions": Array [
+        Object {
+          "directives": Array [],
+          "kind": "OperationDefinition",
+          "name": Object {
+            "kind": "Name",
+            "value": "getUser",
+          },
+          "operation": "query",
+          "selectionSet": Object {
+            "kind": "SelectionSet",
+            "selections": Array [
+              Object {
+                "alias": undefined,
+                "arguments": Array [
+                  Object {
+                    "kind": "Argument",
+                    "name": Object {
+                      "kind": "Name",
+                      "value": "name",
+                    },
+                    "value": Object {
+                      "kind": "Variable",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "name",
+                      },
+                    },
+                  },
+                ],
+                "directives": Array [],
+                "kind": "Field",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "user",
+                },
+                "selectionSet": Object {
+                  "kind": "SelectionSet",
+                  "selections": Array [
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "id",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "firstName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "lastName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                  ],
+                },
+              },
+            ],
+          },
+          "variableDefinitions": Array [
+            Object {
+              "defaultValue": undefined,
+              "directives": Array [],
+              "kind": "VariableDefinition",
+              "type": Object {
+                "kind": "NamedType",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "String",
+                },
+              },
+              "variable": Object {
+                "kind": "Variable",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "name",
+                },
+              },
+            },
+          ],
+        },
+      ],
+      "kind": "Document",
+      "loc": Object {
+        "end": 124,
+        "start": 0,
+      },
+    },
+    "variables": Object {
+      "name": "Clara",
+    },
+  },
+}
+`;
+
+exports[`on error returns error data with status 400 and manual redirect mode 1`] = `
+Object {
+  "data": undefined,
+  "error": [CombinedError: [Network] ],
+  "extensions": undefined,
+  "operation": Object {
+    "context": Object {
+      "fetchOptions": Object {
+        "method": "POST",
+      },
+      "requestPolicy": "cache-first",
+      "url": "http://localhost:3000/graphql",
+    },
+    "key": 2,
+    "operationName": "query",
+    "query": Object {
+      "definitions": Array [
+        Object {
+          "directives": Array [],
+          "kind": "OperationDefinition",
+          "name": Object {
+            "kind": "Name",
+            "value": "getUser",
+          },
+          "operation": "query",
+          "selectionSet": Object {
+            "kind": "SelectionSet",
+            "selections": Array [
+              Object {
+                "alias": undefined,
+                "arguments": Array [
+                  Object {
+                    "kind": "Argument",
+                    "name": Object {
+                      "kind": "Name",
+                      "value": "name",
+                    },
+                    "value": Object {
+                      "kind": "Variable",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "name",
+                      },
+                    },
+                  },
+                ],
+                "directives": Array [],
+                "kind": "Field",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "user",
+                },
+                "selectionSet": Object {
+                  "kind": "SelectionSet",
+                  "selections": Array [
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "id",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "firstName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "lastName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                  ],
+                },
+              },
+            ],
+          },
+          "variableDefinitions": Array [
+            Object {
+              "defaultValue": undefined,
+              "directives": Array [],
+              "kind": "VariableDefinition",
+              "type": Object {
+                "kind": "NamedType",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "String",
+                },
+              },
+              "variable": Object {
+                "kind": "Variable",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "name",
+                },
+              },
+            },
+          ],
+        },
+      ],
+      "kind": "Document",
+      "loc": Object {
+        "end": 124,
+        "start": 0,
+      },
+    },
+    "variables": Object {
+      "name": "Clara",
+    },
+  },
+}
+`;
+
+exports[`on success returns response data 1`] = `
+Object {
+  "data": Object {
+    "data": Object {
+      "user": 1200,
+    },
+  },
+  "error": undefined,
+  "extensions": undefined,
+  "operation": Object {
+    "context": Object {
+      "fetchOptions": Object {
+        "method": "POST",
+      },
+      "requestPolicy": "cache-first",
+      "url": "http://localhost:3000/graphql",
+    },
+    "key": 2,
+    "operationName": "query",
+    "query": Object {
+      "definitions": Array [
+        Object {
+          "directives": Array [],
+          "kind": "OperationDefinition",
+          "name": Object {
+            "kind": "Name",
+            "value": "getUser",
+          },
+          "operation": "query",
+          "selectionSet": Object {
+            "kind": "SelectionSet",
+            "selections": Array [
+              Object {
+                "alias": undefined,
+                "arguments": Array [
+                  Object {
+                    "kind": "Argument",
+                    "name": Object {
+                      "kind": "Name",
+                      "value": "name",
+                    },
+                    "value": Object {
+                      "kind": "Variable",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "name",
+                      },
+                    },
+                  },
+                ],
+                "directives": Array [],
+                "kind": "Field",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "user",
+                },
+                "selectionSet": Object {
+                  "kind": "SelectionSet",
+                  "selections": Array [
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "id",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "firstName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "lastName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                  ],
+                },
+              },
+            ],
+          },
+          "variableDefinitions": Array [
+            Object {
+              "defaultValue": undefined,
+              "directives": Array [],
+              "kind": "VariableDefinition",
+              "type": Object {
+                "kind": "NamedType",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "String",
+                },
+              },
+              "variable": Object {
+                "kind": "Variable",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "name",
+                },
+              },
+            },
+          ],
+        },
+      ],
+      "kind": "Document",
+      "loc": Object {
+        "end": 124,
+        "start": 0,
+      },
+    },
+    "variables": Object {
+      "name": "Clara",
+    },
+  },
+}
+`;
+
+exports[`on success uses the mock fetch if given 1`] = `
+Object {
+  "data": Object {
+    "data": Object {
+      "user": 1200,
+    },
+  },
+  "error": undefined,
+  "extensions": undefined,
+  "operation": Object {
+    "context": Object {
+      "fetch": [MockFunction] {
+        "calls": Array [
+          Array [
+            "https://test.com/graphql",
+            Object {
+              "signal": undefined,
+            },
+          ],
+        ],
+        "results": Array [
+          Object {
+            "type": "return",
+            "value": Promise {},
+          },
+        ],
+      },
+      "fetchOptions": Object {
+        "method": "POST",
+      },
+      "requestPolicy": "cache-first",
+      "url": "http://localhost:3000/graphql",
+    },
+    "key": 2,
+    "operationName": "query",
+    "query": Object {
+      "definitions": Array [
+        Object {
+          "directives": Array [],
+          "kind": "OperationDefinition",
+          "name": Object {
+            "kind": "Name",
+            "value": "getUser",
+          },
+          "operation": "query",
+          "selectionSet": Object {
+            "kind": "SelectionSet",
+            "selections": Array [
+              Object {
+                "alias": undefined,
+                "arguments": Array [
+                  Object {
+                    "kind": "Argument",
+                    "name": Object {
+                      "kind": "Name",
+                      "value": "name",
+                    },
+                    "value": Object {
+                      "kind": "Variable",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "name",
+                      },
+                    },
+                  },
+                ],
+                "directives": Array [],
+                "kind": "Field",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "user",
+                },
+                "selectionSet": Object {
+                  "kind": "SelectionSet",
+                  "selections": Array [
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "id",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "firstName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                    Object {
+                      "alias": undefined,
+                      "arguments": Array [],
+                      "directives": Array [],
+                      "kind": "Field",
+                      "name": Object {
+                        "kind": "Name",
+                        "value": "lastName",
+                      },
+                      "selectionSet": undefined,
+                    },
+                  ],
+                },
+              },
+            ],
+          },
+          "variableDefinitions": Array [
+            Object {
+              "defaultValue": undefined,
+              "directives": Array [],
+              "kind": "VariableDefinition",
+              "type": Object {
+                "kind": "NamedType",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "String",
+                },
+              },
+              "variable": Object {
+                "kind": "Variable",
+                "name": Object {
+                  "kind": "Name",
+                  "value": "name",
+                },
+              },
+            },
+          ],
+        },
+      ],
+      "kind": "Document",
+      "loc": Object {
+        "end": 124,
+        "start": 0,
+      },
+    },
+    "variables": Object {
+      "name": "Clara",
+    },
+  },
+}
+`;
diff --git a/packages/core/src/internal/fetchSource.test.ts b/packages/core/src/internal/fetchSource.test.ts
new file mode 100644
index 00000000..c8203108
--- /dev/null
+++ b/packages/core/src/internal/fetchSource.test.ts
@@ -0,0 +1,154 @@
+import { pipe, subscribe, toPromise } from 'wonka';
+
+import { queryOperation } from '../test-utils';
+import { makeFetchSource } from './fetchSource';
+
+const fetch = (global as any).fetch as jest.Mock;
+const abort = jest.fn();
+
+const abortError = new Error();
+abortError.name = 'AbortError';
+
+beforeAll(() => {
+  (global as any).AbortController = function AbortController() {
+    this.signal = undefined;
+    this.abort = abort;
+  };
+});
+
+afterEach(() => {
+  fetch.mockClear();
+  abort.mockClear();
+});
+
+afterAll(() => {
+  (global as any).AbortController = undefined;
+});
+
+const response = {
+  status: 200,
+  data: {
+    data: {
+      user: 1200,
+    },
+  },
+};
+
+describe('on success', () => {
+  beforeEach(() => {
+    fetch.mockResolvedValue({
+      status: 200,
+      json: jest.fn().mockResolvedValue(response),
+    });
+  });
+
+  it('returns response data', async () => {
+    const fetchOptions = {};
+    const data = await pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+
+    expect(fetch).toHaveBeenCalled();
+    expect(fetch.mock.calls[0][0]).toBe('https://test.com/graphql');
+    expect(fetch.mock.calls[0][1]).toBe(fetchOptions);
+  });
+
+  it('uses the mock fetch if given', async () => {
+    const fetchOptions = {};
+    const fetcher = jest.fn().mockResolvedValue({
+      status: 200,
+      json: jest.fn().mockResolvedValue(response),
+    });
+
+    const data = await pipe(
+      makeFetchSource(
+        {
+          ...queryOperation,
+          context: {
+            ...queryOperation.context,
+            fetch: fetcher,
+          },
+        },
+        'https://test.com/graphql',
+        fetchOptions
+      ),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+    expect(fetch).not.toHaveBeenCalled();
+    expect(fetcher).toHaveBeenCalled();
+  });
+});
+
+describe('on error', () => {
+  beforeEach(() => {
+    fetch.mockResolvedValue({
+      status: 400,
+      json: jest.fn().mockResolvedValue({}),
+    });
+  });
+
+  it('returns error data', async () => {
+    const fetchOptions = {};
+    const data = await pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+  });
+
+  it('returns error data with status 400 and manual redirect mode', async () => {
+    const data = await pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', {
+        redirect: 'manual',
+      }),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+  });
+
+  it('ignores the error when a result is available', async () => {
+    const data = await pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', {}),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+  });
+});
+
+describe('on teardown', () => {
+  it('does not start the outgoing request on immediate teardowns', () => {
+    fetch.mockRejectedValueOnce(abortError);
+
+    const { unsubscribe } = pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', {}),
+      subscribe(fail)
+    );
+
+    unsubscribe();
+    expect(fetch).toHaveBeenCalledTimes(0);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+
+  it('aborts the outgoing request', async () => {
+    fetch.mockRejectedValueOnce(abortError);
+
+    const { unsubscribe } = pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', {}),
+      subscribe(fail)
+    );
+
+    await Promise.resolve();
+
+    unsubscribe();
+    expect(fetch).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+});