Repository: FormidableLabs/urql
Pull request #375 (2019-08-06 15:33:39)

Make further bundle size optimisations
This accomplishes a couple of things.

Firstly it sets up Rollup to compile `process.env.NODE_ENV` to be `"production"` for our minified bundles so those accurately reflect what `urql` would compile to in a production environment.

Then it adds some initial bundle size optimisations:

- Remove rest spread from Query/Subscription components as it's not needed
- Replace `debugExchange` with a noop in production
- Remove usage of `addMetadata` with latency times from `fetchExchange` in production
- Remove fallback and fetch error for unknown operations in production
- Extract response/error parsing from `fetchExchange` and `subscriptionExchange` into a common file

This appears to currently save us about `0.3kB (4.62 kB => 4.32kB) min+gzip`


Patch:
diff --git a/docs/exchanges.md b/docs/exchanges.md
index 1ea2d785..584d1f83 100644
--- a/docs/exchanges.md
+++ b/docs/exchanges.md
@@ -1,16 +1,18 @@
 # Exchanges
 
-## Packaged
+## Packages
 
 These exchanges can be imported from the `urql` package.
 
-- cacheExchange - caches all incoming data
-- debugExchange - gives information about outgoing graphql requests in log format
-- dedupExchange - deduplicates outgoing queries
-- fetchExchange - used to manage the outgoing graphql requests
-- subscriptionExchange - used to support subscriptions
+- `cacheExchange`: the default document cache implementation
+- `debugExchange`: logs information about ongoing operations and results
+- `dedupExchange`: deduplicates ongoing operations
+- `fetchExchange`: sends operations to GraphQL HTTP endpoints and resolves results
+- `ssrExchange`: used to cache results during SSR and rehydrate them on the client-side
+- `subscriptionExchange`: used to support GraphQL subscriptions
 
-## Third-party
+## Addons
 
-- [Persisted Queries](https://github.com/Daniel15/urql-persisted-queries) -
-  Uses persisted query hashes to send graphql-queries if possible
+- [`@urql/devtools`](https://github.com/FormidableLabs/urql-devtools): A Chrome extension for monitoring and debugging
+- [`@urql/exchange-suspense`](https://github.com/FormidableLabs/urql-exchange-suspense): An experimental exchange for using `<React.Suspense>`
+- [`urql-persisted-queries`](https://github.com/Daniel15/urql-persisted-queries): An exchange for adding persisted query support
diff --git a/package.json b/package.json
index b7a7cb01..f2d9a93b 100644
--- a/package.json
+++ b/package.json
@@ -122,6 +122,7 @@
     "rollup-plugin-buble": "^0.19.8",
     "rollup-plugin-commonjs": "^10.0.1",
     "rollup-plugin-node-resolve": "^5.2.0",
+    "rollup-plugin-replace": "^2.2.0",
     "rollup-plugin-terser": "^5.1.1",
     "rollup-plugin-typescript2": "^0.22.0",
     "terser": "^4.1.2",
@@ -135,8 +136,6 @@
   },
   "dependencies": {
     "fast-json-stable-stringify": "^2.0.0",
-    "graphql-tag": "^2.10.1",
-    "prop-types": "^15.6.0",
     "wonka": "^3.2.0"
   }
 }
diff --git a/rollup.config.js b/rollup.config.js
index 26b36e03..fed9311d 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -4,6 +4,7 @@ import nodeResolve from 'rollup-plugin-node-resolve';
 import typescript from 'rollup-plugin-typescript2';
 import buble from 'rollup-plugin-buble';
 import babel from 'rollup-plugin-babel';
+import replace from 'rollup-plugin-replace';
 import { terser } from 'rollup-plugin-terser';
 
 const pkgInfo = require('./package.json');
@@ -131,8 +132,11 @@ const makePlugins = (isProduction = false) => [
       }]
     ]
   }),
+  isProduction && replace({
+    'process.env.NODE_ENV': JSON.stringify('production')
+  }),
   isProduction ? terserMinified : terserPretty
-];
+].filter(Boolean);
 
 const config = {
   input: './src/index.ts',
@@ -167,16 +171,17 @@ export default [
   }, {
     ...config,
     plugins: makePlugins(true),
+    onwarn: () => {},
     output: [
       {
-        sourcemap: true,
+        sourcemap: false,
         legacy: true,
         freeze: false,
         file: './dist/urql.min.js',
         format: 'cjs'
       },
       {
-        sourcemap: true,
+        sourcemap: false,
         legacy: true,
         freeze: false,
         file: './dist/urql.es.min.js',
diff --git a/src/client.ts b/src/client.ts
index e414628d..23e354c2 100755
--- a/src/client.ts
+++ b/src/client.ts
@@ -23,6 +23,7 @@ import {
   OperationContext,
   OperationResult,
   OperationType,
+  RequestPolicy,
 } from './types';
 
 import { toSuspenseSource } from './utils';
@@ -39,6 +40,8 @@ export interface ClientOptions {
   exchanges?: Exchange[];
   /** Activates support for Suspense. */
   suspense?: boolean;
+  /** The default request policy for requests. */
+  requestPolicy?: RequestPolicy;
 }
 
 interface ActiveOperations {
@@ -55,6 +58,7 @@ export class Client {
   fetchOptions?: RequestInit | (() => RequestInit);
   exchange: Exchange;
   suspense: boolean;
+  requestPolicy: RequestPolicy;
 
   // These are internals to be used to keep track of operations
   dispatchOperation: (operation: Operation) => void;
@@ -67,6 +71,7 @@ export class Client {
     this.fetchOptions = opts.fetchOptions;
     this.fetch = opts.fetch;
     this.suspense = !!opts.suspense;
+    this.requestPolicy = opts.requestPolicy || 'cache-first';
 
     // This subject forms the input of operations; executeOperation may be
     // called to dispatch a new operation on the subject
@@ -111,7 +116,7 @@ export class Client {
   private createOperationContext = (
     opts?: Partial<OperationContext>
   ): OperationContext => {
-    const { requestPolicy = 'cache-first' } = opts || {};
+    const { requestPolicy = this.requestPolicy } = opts || {};
 
     return {
       url: this.url,
diff --git a/src/components/Query.ts b/src/components/Query.ts
index 6f0f4c8f..f33b1235 100644
--- a/src/components/Query.ts
+++ b/src/components/Query.ts
@@ -10,10 +10,9 @@ export interface QueryState<T> extends UseQueryState<T> {
   executeQuery: (opts?: Partial<OperationContext>) => void;
 }
 
-export function Query<T = any, V = any>({
-  children,
-  ...args
-}: QueryProps<T, V>): ReactElement<any> {
-  const [state, executeQuery] = useQuery<T, V>(args);
-  return children({ ...state, executeQuery });
+export function Query<T = any, V = any>(
+  props: QueryProps<T, V>
+): ReactElement<any> {
+  const [state, executeQuery] = useQuery<T, V>(props);
+  return props.children({ ...state, executeQuery });
 }
diff --git a/src/components/Subscription.ts b/src/components/Subscription.ts
index 0cac3b02..cfc86dcb 100644
--- a/src/components/Subscription.ts
+++ b/src/components/Subscription.ts
@@ -12,11 +12,9 @@ export interface SubscriptionProps<T, R, V> extends UseSubscriptionArgs<V> {
   children: (arg: UseSubscriptionState<R>) => ReactElement<any>;
 }
 
-export function Subscription<T = any, R = T, V = any>({
-  children,
-  handler,
-  ...args
-}: SubscriptionProps<T, R, V>): ReactElement<any> {
-  const [state] = useSubscription<T, R, V>(args, handler);
-  return children(state);
+export function Subscription<T = any, R = T, V = any>(
+  props: SubscriptionProps<T, R, V>
+): ReactElement<any> {
+  const [state] = useSubscription<T, R, V>(props, props.handler);
+  return props.children(state);
 }
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index ce37aa94..a5279c81 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -56,9 +56,8 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
       sharedOps$,
       filter(op => !shouldSkip(op) && isOperationCached(op)),
       map(operation => {
-        const { key, context } = operation;
-        const cachedResult = resultCache.get(key);
-        if (context.requestPolicy === 'cache-and-network') {
+        const cachedResult = resultCache.get(operation.key);
+        if (operation.context.requestPolicy === 'cache-and-network') {
           reexecuteOperation(client, operation);
         }
 
@@ -135,7 +134,9 @@ export const afterMutation = (
   collectTypesFromResponse(response.data).forEach(typeName => {
     const operations =
       operationCache[typeName] || (operationCache[typeName] = new Set());
-    operations.forEach(key => pendingOperations.add(key));
+    operations.forEach(key => {
+      pendingOperations.add(key);
+    });
     operations.clear();
   });
 
diff --git a/src/exchanges/debug.ts b/src/exchanges/debug.ts
index c4db97ac..66048d52 100644
--- a/src/exchanges/debug.ts
+++ b/src/exchanges/debug.ts
@@ -2,15 +2,19 @@ import { pipe, tap } from 'wonka';
 import { Exchange } from '../types';
 
 export const debugExchange: Exchange = ({ forward }) => {
-  return ops$ =>
-    pipe(
-      ops$,
-      // eslint-disable-next-line no-console
-      tap(op => console.log('[Exchange debug]: Incoming operation: ', op)),
-      forward,
-      tap(result =>
+  if (process.env.NODE_ENV === 'production') {
+    return ops$ => forward(ops$);
+  } else {
+    return ops$ =>
+      pipe(
+        ops$,
         // eslint-disable-next-line no-console
-        console.log('[Exchange debug]: Completed operation: ', result)
-      )
-    );
+        tap(op => console.log('[Exchange debug]: Incoming operation: ', op)),
+        forward,
+        tap(result =>
+          // eslint-disable-next-line no-console
+          console.log('[Exchange debug]: Completed operation: ', result)
+        )
+      );
+  }
 };
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index cc381855..c2238105 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -2,7 +2,7 @@
 import { print } from 'graphql';
 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
 import { Exchange, Operation, OperationResult } from '../types';
-import { addMetadata, CombinedError } from '../utils';
+import { addMetadata, makeResult, makeErrorResult } from '../utils';
 
 /** A default exchange for fetching GraphQL requests. */
 export const fetchExchange: Exchange = ({ forward }) => {
@@ -41,7 +41,10 @@ export const fetchExchange: Exchange = ({ forward }) => {
 };
 
 const createFetchSource = (operation: Operation) => {
-  if (operation.operationName === 'subscription') {
+  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?'
     );
@@ -79,12 +82,16 @@ const createFetchSource = (operation: Operation) => {
 
     executeFetch(operation, fetchOptions).then(result => {
       if (result !== undefined) {
-        next({
-          ...result,
-          operation: addMetadata(result.operation, {
-            networkLatency: Date.now() - startTime,
-          }),
-        });
+        if (process.env.NODE_ENV !== 'production') {
+          next({
+            ...result,
+            operation: addMetadata(result.operation, {
+              networkLatency: Date.now() - startTime,
+            }),
+          });
+        } else {
+          next(result);
+        }
       }
 
       complete();
@@ -99,50 +106,26 @@ const createFetchSource = (operation: Operation) => {
 };
 
 const executeFetch = (operation: Operation, opts: RequestInit) => {
-  let response: Response | undefined;
   const { url, fetch: fetcher } = operation.context;
 
+  let response: Response | undefined;
+
   return (fetcher || fetch)(url, opts)
     .then(res => {
+      const { status } = res;
+      const statusRangeEnd = opts.redirect === 'manual' ? 400 : 300;
       response = res;
-      checkStatus(opts.redirect, res);
-      return res.json();
+
+      if (status < 200 || status >= statusRangeEnd) {
+        throw new Error(res.statusText);
+      } else {
+        return res.json();
+      }
     })
-    .then(result => ({
-      operation,
-      data: result.data,
-      error: Array.isArray(result.errors)
-        ? new CombinedError({
-            graphQLErrors: result.errors,
-            response,
-          })
-        : undefined,
-      extensions:
-        typeof result.extensions === 'object' && result.extensions !== null
-          ? result.extensions
-          : undefined,
-    }))
+    .then(result => makeResult(operation, result, response))
     .catch(err => {
-      if (err.name === 'AbortError') {
-        return undefined;
+      if (err.name !== 'AbortError') {
+        return makeErrorResult(operation, err, response);
       }
-
-      return {
-        operation,
-        data: undefined,
-        error: new CombinedError({
-          networkError: err,
-          response,
-        }),
-        extensions: undefined,
-      };
     });
 };
-
-const checkStatus = (redirectMode: string = 'follow', response: Response) => {
-  const statusRangeEnd = redirectMode === 'manual' ? 400 : 300;
-
-  if (response.status < 200 || response.status >= statusRangeEnd) {
-    throw new Error(response.statusText);
-  }
-};
diff --git a/src/exchanges/subscription.ts b/src/exchanges/subscription.ts
index 86846484..3c7eb2e7 100644
--- a/src/exchanges/subscription.ts
+++ b/src/exchanges/subscription.ts
@@ -11,7 +11,7 @@ import {
   takeUntil,
 } from 'wonka';
 
-import { CombinedError } from '../utils/error';
+import { makeResult, makeErrorResult } from '../utils';
 
 import {
   Exchange,
@@ -72,39 +72,12 @@ export const subscriptionExchange = ({
     });
 
     return make<OperationResult>(([next, complete]) => {
-      // TODO: The conversion of the result here is very similar to fetch;
-      // We can maybe extract the logic into generic GraphQL utilities
       const sub = observableish.subscribe({
-        next: result =>
-          next({
-            operation,
-            data: result.data || undefined,
-            error: Array.isArray(result.errors)
-              ? new CombinedError({
-                  graphQLErrors: result.errors,
-                  response: undefined,
-                })
-              : undefined,
-            extensions:
-              typeof result.extensions === 'object' &&
-              result.extensions !== null
-                ? result.extensions
-                : undefined,
-          }),
-        error: err =>
-          next({
-            operation,
-            data: undefined,
-            extensions: undefined,
-            error: new CombinedError({
-              networkError: err,
-              response: undefined,
-            }),
-          }),
+        next: result => next(makeResult(operation, result)),
+        error: err => next(makeErrorResult(operation, err)),
         complete,
       });
 
-      // NOTE: Destructuring sub is avoided here to preserve its potential binding
       return () => sub.unsubscribe();
     });
   };
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 58b57faa..f7c86247 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,3 +1,4 @@
 export * from './useMutation';
 export * from './useQuery';
 export * from './useSubscription';
+export * from './useDevtoolsContext';
diff --git a/src/utils/error.ts b/src/utils/error.ts
index f26fc609..c80d8cd5 100644
--- a/src/utils/error.ts
+++ b/src/utils/error.ts
@@ -6,8 +6,7 @@ const generateErrorMessage = (
 ) => {
   let error = '';
   if (networkErr !== undefined) {
-    error = `[Network] ${networkErr.message}`;
-    return error;
+    return (error = `[Network] ${networkErr.message}`);
   }
 
   if (graphQlErrs !== undefined) {
diff --git a/src/utils/hash.ts b/src/utils/hash.ts
new file mode 100644
index 00000000..58ab442c
--- /dev/null
+++ b/src/utils/hash.ts
@@ -0,0 +1,14 @@
+// When we have separate strings it's useful to run a progressive
+// version of djb2 where we pretend that we're still looping over
+// the same string
+export const phash = (h: number, x: string): number => {
+  h = h | 0;
+  for (let i = 0, l = x.length | 0; i < l; i++) {
+    h = (h << 5) + h + x.charCodeAt(i);
+  }
+
+  return h;
+};
+
+// This is a djb2 hashing function
+export const hash = (x: string): number => phash(5381 | 0, x) >>> 0;
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 49861b08..7d2e909d 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,6 +1,6 @@
 export * from './error';
-export * from './keyForQuery';
 export * from './request';
+export * from './result';
 export * from './typenames';
 export * from './toSuspenseSource';
 
diff --git a/src/utils/keyForQuery.ts b/src/utils/keyForQuery.ts
deleted file mode 100644
index 7ff9b5b0..00000000
--- a/src/utils/keyForQuery.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import stringify from 'fast-json-stable-stringify';
-import { DocumentNode, print } from 'graphql';
-
-interface NameCache {
-  [key: string]: string;
-}
-
-interface WithNameProperty {
-  name?: { value: string };
-  [key: string]: any;
-}
-
-interface WithCacheProperty {
-  __key?: number;
-  [key: string]: any;
-}
-
-const hash = (x: string): number => {
-  /* prettier-ignore */
-  for (var h = 5381 | 0, i = 0, l = x.length | 0; i < l; i++)
-    h = ((h << 5) + h) + x.charCodeAt(i);
-  return h >>> 0;
-};
-
-const docNameCache = Object.create(null) as NameCache;
-
-export const getKeyForQuery = (doc: DocumentNode): number => {
-  if ((doc as WithCacheProperty).__key !== undefined) {
-    return (doc as WithCacheProperty).__key as number;
-  }
-
-  // Using print() can be expensive, so we just check the definition nodes' names
-  let name = doc.definitions.reduce((acc, definition) => {
-    const node = definition as WithNameProperty;
-    return acc + (node.name !== undefined ? node.name.value : '');
-  }, '');
-
-  // This is for inputs that are not using constant references. In such a case
-  // we use the Document names, but we can't use those if they're not unique
-  if (process.env.NODE_ENV !== 'production' && name !== '') {
-    const printed = print(doc);
-    if (!(name in docNameCache)) {
-      docNameCache[name] = printed;
-    } else if (docNameCache[name] !== printed) {
-      console.warn(
-        'Warning: Encountered multiple DocumentNodes with the same name.'
-      );
-    }
-  }
-
-  // If no name is present the stringified document is used
-  if (name === '') {
-    name = print(doc);
-  }
-
-  const key = hash(name);
-  (doc as WithCacheProperty).__key = key;
-  return key;
-};
-
-export const getKeyForRequest = (
-  query: DocumentNode,
-  vars?: object
-): number => {
-  const docKey = getKeyForQuery(query);
-  if (vars === undefined || vars === null) {
-    return docKey;
-  }
-
-  return hash('' + docKey + stringify(vars));
-};
diff --git a/src/utils/request.ts b/src/utils/request.ts
index a81f8763..f5ab63c3 100644
--- a/src/utils/request.ts
+++ b/src/utils/request.ts
@@ -1,16 +1,39 @@
-import { DocumentNode } from 'graphql';
-import gql from 'graphql-tag';
-import { getKeyForRequest } from './keyForQuery';
+import { DocumentNode, parse, print } from 'graphql';
+import { hash, phash } from './hash';
+import stringify from 'fast-json-stable-stringify';
 import { GraphQLRequest, Operation, OperationContext } from '../types';
 
+interface Documents {
+  [key: number]: DocumentNode;
+}
+
+const hashQuery = (q: string): number => hash(q.replace(/[\s,]+/g, ' ').trim());
+
+const docs: Documents = Object.create(null);
+const keyProp = '__key';
+
 export const createRequest = (
   q: string | DocumentNode,
   vars?: object
 ): GraphQLRequest => {
-  const query = typeof q === 'string' ? gql([q]) : q;
+  let key: number;
+  let query: DocumentNode;
+  if (typeof q === 'string') {
+    key = hashQuery(q);
+    query = docs[key] !== undefined ? docs[key] : parse(q);
+  } else if ((q as any)[keyProp] !== undefined) {
+    key = (q as any)[keyProp];
+    query = q;
+  } else {
+    key = hashQuery(print(q));
+    query = docs[key] !== undefined ? docs[key] : q;
+  }
+
+  docs[key] = query;
+  (query as any)[keyProp] = key;
 
   return {
-    key: getKeyForRequest(query, vars),
+    key: vars ? phash(key, stringify(vars)) >>> 0 : key,
     query,
     variables: vars || {},
   };
diff --git a/src/utils/result.ts b/src/utils/result.ts
new file mode 100644
index 00000000..501cc65a
--- /dev/null
+++ b/src/utils/result.ts
@@ -0,0 +1,35 @@
+import { Operation, OperationResult } from '../types';
+import { CombinedError } from './error';
+
+export const makeResult = (
+  operation: Operation,
+  result: any,
+  response?: any
+): OperationResult => ({
+  operation,
+  data: result.data,
+  error: Array.isArray(result.errors)
+    ? new CombinedError({
+        graphQLErrors: result.errors,
+        response,
+      })
+    : undefined,
+  extensions:
+    typeof result.extensions === 'object' && result.extensions !== null
+      ? result.extensions
+      : undefined,
+});
+
+export const makeErrorResult = (
+  operation: Operation,
+  error: Error,
+  response?: any
+): OperationResult => ({
+  operation,
+  data: undefined,
+  error: new CombinedError({
+    networkError: error,
+    response,
+  }),
+  extensions: undefined,
+});
diff --git a/src/utils/typenames.ts b/src/utils/typenames.ts
index 731df97d..d4979b5b 100755
--- a/src/utils/typenames.ts
+++ b/src/utils/typenames.ts
@@ -13,7 +13,9 @@ interface EntityLike {
 
 const collectTypes = (obj: EntityLike | EntityLike[], types: string[] = []) => {
   if (Array.isArray(obj)) {
-    obj.forEach(inner => collectTypes(inner, types));
+    obj.forEach(inner => {
+      collectTypes(inner, types);
+    });
   } else if (typeof obj === 'object' && obj !== null) {
     for (const key in obj) {
       if (Object.prototype.hasOwnProperty.call(obj, key)) {
diff --git a/yarn.lock b/yarn.lock
index 0308f884..a4e398f4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4816,15 +4816,6 @@ prop-types-exact@^1.2.0:
     object.assign "^4.1.0"
     reflect.ownkeys "^0.2.0"
 
-prop-types@^15.6.0, prop-types@^15.7.2:
-  version "15.7.2"
-  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
-  integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
-  dependencies:
-    loose-envify "^1.4.0"
-    object-assign "^4.1.1"
-    react-is "^16.8.1"
-
 prop-types@^15.6.2:
   version "15.6.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
@@ -4833,6 +4824,15 @@ prop-types@^15.6.2:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+prop-types@^15.7.2:
+  version "15.7.2"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
+  integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
+  dependencies:
+    loose-envify "^1.4.0"
+    object-assign "^4.1.1"
+    react-is "^16.8.1"
+
 psl@^1.1.24:
   version "1.1.32"
   resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.32.tgz#3f132717cf2f9c169724b2b6caf373cf694198db"
@@ -5279,6 +5279,14 @@ rollup-plugin-node-resolve@^5.2.0:
     resolve "^1.11.1"
     rollup-pluginutils "^2.8.1"
 
+rollup-plugin-replace@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz#f41ae5372e11e7a217cde349c8b5d5fd115e70e3"
+  integrity sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==
+  dependencies:
+    magic-string "^0.25.2"
+    rollup-pluginutils "^2.6.0"
+
 rollup-plugin-terser@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-5.1.1.tgz#e9d2545ec8d467f96ba99b9216d2285aad8d5b66"
@@ -5300,7 +5308,7 @@ rollup-plugin-typescript2@^0.22.0:
     rollup-pluginutils "2.8.1"
     tslib "1.10.0"
 
-rollup-pluginutils@2.8.1, rollup-pluginutils@^2.8.1:
+rollup-pluginutils@2.8.1, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1:
   version "2.8.1"
   resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz#8fa6dd0697344938ef26c2c09d2488ce9e33ce97"
   integrity sha512-J5oAoysWar6GuZo0s+3bZ6sVZAC0pfqKz68De7ZgDi5z63jOVZn1uJL/+z1jeKHNbGII8kAyHF5q8LnxSX5lQg==


Test patch:
diff --git a/src/__snapshots__/client.test.ts.snap b/src/__snapshots__/client.test.ts.snap
index 4885bab4..ec12ddf3 100644
--- a/src/__snapshots__/client.test.ts.snap
+++ b/src/__snapshots__/client.test.ts.snap
@@ -14,6 +14,7 @@ Client {
   "fetchOptions": undefined,
   "operations$": [Function],
   "reexecuteOperation": [Function],
+  "requestPolicy": "cache-first",
   "results$": [Function],
   "suspense": false,
   "url": "https://hostname.com",
diff --git a/src/__snapshots__/context.test.ts.snap b/src/__snapshots__/context.test.ts.snap
index 3a40f8a5..f223c558 100644
--- a/src/__snapshots__/context.test.ts.snap
+++ b/src/__snapshots__/context.test.ts.snap
@@ -27,6 +27,7 @@ Object {
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
+      "requestPolicy": "cache-first",
       "results$": [Function],
       "suspense": false,
       "url": "/graphql",
@@ -44,6 +45,7 @@ Object {
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
+      "requestPolicy": "cache-first",
       "results$": [Function],
       "suspense": false,
       "url": "/graphql",
@@ -80,6 +82,7 @@ Object {
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
+      "requestPolicy": "cache-first",
       "results$": [Function],
       "suspense": false,
       "url": "/graphql",
@@ -97,6 +100,7 @@ Object {
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
+      "requestPolicy": "cache-first",
       "results$": [Function],
       "suspense": false,
       "url": "/graphql",
diff --git a/src/client.test.ts b/src/client.test.ts
index 8d656d32..7552c29f 100755
--- a/src/client.test.ts
+++ b/src/client.test.ts
@@ -3,11 +3,6 @@ import gql from 'graphql-tag';
 
 /** NOTE: Testing in this file is designed to test both the client and it's interaction with default Exchanges */
 
-jest.mock('./utils/keyForQuery', () => ({
-  getKeyForQuery: () => 123,
-  getKeyForRequest: () => 123,
-}));
-
 import { map, pipe, subscribe, tap } from 'wonka';
 import { createClient } from './client';
 
@@ -50,7 +45,11 @@ beforeEach(() => {
   receivedOps = [];
   exchangeMock.mockClear();
   receiveMock.mockClear();
-  client = createClient({ url, exchanges: [exchangeMock] as any[] });
+  client = createClient({
+    url,
+    exchanges: [exchangeMock] as any[],
+    requestPolicy: 'cache-and-network',
+  });
 });
 
 describe('exchange args', () => {
@@ -85,6 +84,30 @@ describe('executeQuery', () => {
     expect(receivedOps[0]).toHaveProperty('variables', query.variables);
   });
 
+  it('passes requestPolicy to exchange', () => {
+    pipe(
+      client.executeQuery(query),
+      subscribe(x => x)
+    );
+
+    expect(receivedOps[0].context).toHaveProperty(
+      'requestPolicy',
+      'cache-and-network'
+    );
+  });
+
+  it('allows overriding the requestPolicy', () => {
+    pipe(
+      client.executeQuery(query, { requestPolicy: 'cache-first' }),
+      subscribe(x => x)
+    );
+
+    expect(receivedOps[0].context).toHaveProperty(
+      'requestPolicy',
+      'cache-first'
+    );
+  });
+
   it('passes operationName type to exchange', () => {
     pipe(
       client.executeQuery(query),
diff --git a/src/utils/keyForQuery.test.ts b/src/utils/keyForQuery.test.ts
deleted file mode 100644
index b94914aa..00000000
--- a/src/utils/keyForQuery.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import gql from 'graphql-tag';
-import { getKeyForQuery, getKeyForRequest } from './keyForQuery';
-
-const consoleWarn = console.warn;
-
-beforeEach(() => {
-  console.warn = jest.fn();
-});
-
-afterAll(() => {
-  console.warn = consoleWarn;
-});
-
-it('returns consistent hashes for DocumentNodes', () => {
-  const query = gql`
-    query Test {
-      test
-    }
-  `;
-
-  const different = gql`
-    query TestB {
-      test2
-    }
-  `;
-
-  expect((query as any).__key).toBe(undefined);
-
-  expect(getKeyForQuery(query)).toBe(getKeyForQuery(query));
-  expect(getKeyForQuery(query)).not.toBe(getKeyForQuery(different));
-
-  expect((query as any).__key).toBe(getKeyForQuery(query));
-});
-
-it('guesses the same hash for similar DocumentNodes', () => {
-  const query = () => gql`
-    query Test {
-      test
-    }
-  `;
-
-  const different = () => gql`
-    query TestB {
-      test2
-    }
-  `;
-
-  expect(getKeyForQuery(query())).toBe(getKeyForQuery(query()));
-  expect(getKeyForQuery(query())).not.toBe(getKeyForQuery(different()));
-});
-
-it('guesses the same hash for unnamed but similar DocumentNodes', () => {
-  const query = () => gql`
-    {
-      test
-    }
-  `;
-
-  const different = () => gql`
-    {
-      test2
-    }
-  `;
-
-  expect(getKeyForQuery(query())).toBe(getKeyForQuery(query()));
-  expect(getKeyForQuery(query())).not.toBe(getKeyForQuery(different()));
-});
-
-it('warns about different queries having identical names', () => {
-  const query = gql`
-    query SameName {
-      test
-    }
-  `;
-
-  const different = gql`
-    query SameName {
-      test2
-    }
-  `;
-
-  // This is expected but obviously problematic
-  expect(getKeyForQuery(query)).toBe(getKeyForQuery(different));
-
-  expect(console.warn).toHaveBeenCalled();
-});
-
-it('can key variables', () => {
-  const query = gql`
-    query SameName {
-      test
-    }
-  `;
-
-  const vars = { test: true };
-
-  expect(getKeyForQuery(query)).toBe(getKeyForRequest(query, undefined));
-  expect(getKeyForRequest(query, vars)).toBe(getKeyForRequest(query, vars));
-  expect(getKeyForRequest(query)).not.toBe(getKeyForRequest(query, vars));
-  expect(getKeyForRequest(query, { test: 2 })).not.toBe(
-    getKeyForRequest(query, vars)
-  );
-});
diff --git a/src/utils/request.test.ts b/src/utils/request.test.ts
index 6e206f13..82e87f74 100644
--- a/src/utils/request.test.ts
+++ b/src/utils/request.test.ts
@@ -1,21 +1,54 @@
-import { print } from 'graphql';
+import { parse, print } from 'graphql';
 import gql from 'graphql-tag';
 import { createRequest } from './request';
 
-const doc = print(
-  gql`
+it('should hash identical queries identically', () => {
+  const reqA = createRequest('{ test }');
+  const reqB = createRequest('{ test }');
+  expect(reqA.key).toBe(reqB.key);
+});
+
+it('should hash identical DocumentNodes identically', () => {
+  const reqA = createRequest(parse('{ testB }'));
+  const reqB = createRequest(parse('{ testB }'));
+  expect(reqA.key).toBe(reqB.key);
+  expect(reqA.query).toBe(reqB.query);
+});
+
+it('should use the hash from a key if available', () => {
+  const doc = parse('{ testC }');
+  (doc as any).__key = 1234;
+  const req = createRequest(doc);
+  expect(req.key).toBe(1234);
+});
+
+it('should hash DocumentNodes and strings identically', () => {
+  const docA = parse('{ field }');
+  const docB = print(docA).replace(/\s/g, ' ');
+  const reqA = createRequest(docA);
+  const reqB = createRequest(docB);
+  expect(reqA.key).toBe(reqB.key);
+  expect(reqA.query).toBe(reqB.query);
+});
+
+it('should hash graphql-tag documents correctly', () => {
+  const doc = gql`
     {
-      todos {
-        id
-      }
+      testD
     }
-  `
-);
+  `;
+  createRequest(doc);
+  expect((doc as any).__key).not.toBe(undefined);
+});
 
 it('should return a valid query object', () => {
+  const doc = gql`
+    {
+      testE
+    }
+  `;
   const val = createRequest(doc);
 
-  expect(print(val.query)).toBe(doc);
   expect(val).toMatchObject({
     key: expect.any(Number),
     query: expect.any(Object),
@@ -24,6 +57,13 @@ it('should return a valid query object', () => {
 });
 
 it('should return a valid query object with variables', () => {
+  const doc = print(
+    gql`
+      {
+        testF
+      }
+    `
+  );
   const val = createRequest(doc, { test: 5 });
 
   expect(print(val.query)).toBe(doc);