Repository: FormidableLabs/urql
Pull request #314 (2019-06-18 16:00:38)

(docs) - propose way for third-party exchanges
This is a proposition as to how we could acknowledge exchanges made by others.

This is meant for people who want to add their own package, we could expand this with a guide on how to write your own exchanges etc, I think people who write exchanges that help others should be able to advertise it somehow on the main repository. Correct me if my thought process is wrong somewhere.


Patch:
diff --git a/.sail.yml b/.sail.yml
index f315f9e9..52f77b86 100644
--- a/.sail.yml
+++ b/.sail.yml
@@ -42,6 +42,7 @@ tasks:
     command:
       - yarn
     args:
+      - run
       - check
   e2e:
     image: cypress/browsers:node10.2.1-chrome74
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8c792d1..ceaf986d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ If a change is missing an attribution, it may have been made by a Core Contribut
 
 _The format is based on [Keep a Changelog](http://keepachangelog.com/)._
 
+## [v1.1.3](https://github.com/FormidableLabs/urql/compare/v1.1.2...v1.1.3)
+
+This is a hotfix that patches a small regression from `1.1.2` where
+`useQuery` would crash due to an incorrect teardown function from pause.
+
+- Fix `executeQuery` dispose function when `pause` is set, by[@JoviDeCroock](https://github.com/JoviDeCroock) (see [#315](https://github.com/FormidableLabs/urql/pull/315))
+
 ## [v1.1.2](https://github.com/FormidableLabs/urql/compare/v1.1.1...v1.1.2)
 
 This patch fixes a small bug that usually manifests in development,
diff --git a/docs/README.md b/docs/README.md
index 46573827..6aa517fd 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -28,6 +28,12 @@ Everything about the basic & default `urql` behaviour
 that comes with explanations of the `fetchExchange`,
 the `cacheExchange`, and how to use _Subscriptions_.
 
+### [Exchanges](exchanges.md)
+
+Here's everything you need to know to customize and augment
+every aspect of the GraphQL client from how data is cached
+to how components receive their data.
+
 ### [Extending & Experimenting](extending-and-experimenting.md)
 
 Here's everything you need to know to extend,
diff --git a/docs/exchanges.md b/docs/exchanges.md
new file mode 100644
index 00000000..1ea2d785
--- /dev/null
+++ b/docs/exchanges.md
@@ -0,0 +1,16 @@
+# Exchanges
+
+## Packaged
+
+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
+
+## Third-party
+
+- [Persisted Queries](https://github.com/Daniel15/urql-persisted-queries) -
+  Uses persisted query hashes to send graphql-queries if possible
diff --git a/examples/3-ssr-with-nextjs/src/init-urql-client.js b/examples/3-ssr-with-nextjs/src/init-urql-client.js
index 5d226126..7e161427 100644
--- a/examples/3-ssr-with-nextjs/src/init-urql-client.js
+++ b/examples/3-ssr-with-nextjs/src/init-urql-client.js
@@ -15,13 +15,14 @@ export default function initUrqlClient(initialState) {
   // Create a new client for every server-side rendered request to reset its state
   // for each rendered page
   // Reuse the client on the client-side however
-  if (!process.browser || !urqlClient) {
+  const isServer = typeof window === 'undefined';
+  if (isServer || !urqlClient) {
     ssrCache = ssrExchange({ initialState });
 
     urqlClient = createClient({
       url: 'https://api.graph.cool/simple/v1/cixmkt2ul01q00122mksg82pn',
       // Active suspense mode on the server-side
-      suspense: !process.browser,
+      suspense: isServer,
       exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange],
     });
   }
diff --git a/examples/3-ssr-with-nextjs/src/with-urql-client.js b/examples/3-ssr-with-nextjs/src/with-urql-client.js
index 4eb08d4b..a8af6181 100644
--- a/examples/3-ssr-with-nextjs/src/with-urql-client.js
+++ b/examples/3-ssr-with-nextjs/src/with-urql-client.js
@@ -16,7 +16,8 @@ const withUrqlClient = App => {
 
       // getInitialProps is universal, but we only want
       // to run server-side rendered suspense on the server
-      if (process.browser) {
+      const isBrowser = typeof window !== "undefined";
+      if (isBrowser) {
         return appProps;
       }
 
diff --git a/package.json b/package.json
index 83aeb3b1..b6f8bcd0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "urql",
-  "version": "1.1.2",
+  "version": "1.1.3",
   "description": "A highly customizable and versatile GraphQL client for React",
   "repository": "https://www.github.com/FormidableLabs/urql",
   "bugs": {
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 762a3876..8c66e402 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -3,7 +3,11 @@ import { filter, map, merge, pipe, share, tap } from 'wonka';
 
 import { Client } from '../client';
 import { Exchange, Operation, OperationResult } from '../types';
-import { collectTypesFromResponse, formatDocument } from '../utils/typenames';
+import {
+  addMetadata,
+  collectTypesFromResponse,
+  formatDocument,
+} from '../utils';
 
 type ResultCache = Map<number, OperationResult>;
 
@@ -52,23 +56,25 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
       sharedOps$,
       filter(op => !shouldSkip(op) && isOperationCached(op)),
       map(operation => {
-        const {
-          key,
-          context: { requestPolicy },
-        } = operation;
+        const { key, context } = operation;
         const cachedResult = resultCache.get(key);
-        if (requestPolicy === 'cache-and-network') {
+        if (context.requestPolicy === 'cache-and-network') {
           reexecuteOperation(client, operation);
         }
 
         if (cachedResult !== undefined) {
-          return cachedResult;
+          return {
+            ...cachedResult,
+            operation: addMetadata(cachedResult.operation, {
+              cacheOutcome: 'hit',
+            }),
+          };
         }
 
         return {
-          operation,
           data: undefined,
           error: undefined,
+          operation: addMetadata(operation, { cacheOutcome: 'miss' }),
         };
       })
     );
@@ -85,6 +91,7 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
           filter(op => shouldSkip(op))
         ),
       ]),
+      map(op => addMetadata(op, { cacheOutcome: 'miss' })),
       forward,
       tap(response => {
         if (
@@ -145,19 +152,17 @@ const afterQuery = (
   resultCache: ResultCache,
   operationCache: OperationCache
 ) => (response: OperationResult) => {
-  const {
-    operation: { key },
-    data,
-  } = response;
+  const { operation, data } = response;
+
   if (data === undefined) {
     return;
   }
 
-  resultCache.set(key, response);
+  resultCache.set(operation.key, response);
 
   collectTypesFromResponse(response.data).forEach(typeName => {
     const operations =
       operationCache[typeName] || (operationCache[typeName] = new Set());
-    operations.add(key);
+    operations.add(operation.key);
   });
 };
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index b54a6ea7..9208e6eb 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 { CombinedError } from '../utils/error';
+import { addMetadata, CombinedError } from '../utils';
 
 /** A default exchange for fetching GraphQL requests. */
 export const fetchExchange: Exchange = ({ forward }) => {
@@ -75,9 +75,15 @@ const createFetchSource = (operation: Operation) => {
         abortController !== undefined ? abortController.signal : undefined,
     };
 
+    const startTime = Date.now();
     executeFetch(operation, fetchOptions).then(result => {
       if (result !== undefined) {
-        next(result);
+        next({
+          ...result,
+          operation: addMetadata(result.operation, {
+            networkLatency: Date.now() - startTime,
+          }),
+        });
       }
 
       complete();
diff --git a/src/hooks/useDevtoolsContext.ts b/src/hooks/useDevtoolsContext.ts
new file mode 100644
index 00000000..dc10d668
--- /dev/null
+++ b/src/hooks/useDevtoolsContext.ts
@@ -0,0 +1,28 @@
+import { useMemo, useRef } from 'react';
+
+/** Find the name of the calling component. */
+const getHookParent = () => {
+  /**
+   * Line values could be:
+   * - named export             " at exports.Home (Home.tsx?9b1b:17)"
+   * - default export / inline  " at Home (Home.tsx?9b1b:17)"
+   */
+  const stackLine = (new Error().stack as string).split('\n')[4];
+  const parsedLine = /.*at (exports\.)?(\w+)/.exec(stackLine);
+
+  return parsedLine === null || typeof parsedLine[2] !== 'string'
+    ? 'Unknown'
+    : parsedLine[2];
+};
+
+const useDevtoolsContextHook = () => {
+  const source = useRef(getHookParent());
+
+  return useMemo(() => [{ meta: { source: source.current } }], []);
+};
+
+/** Creates additional context values for serving metadata to devtools. */
+export const useDevtoolsContext =
+  process.env.NODE_ENV === 'production'
+    ? () => [undefined]
+    : useDevtoolsContextHook;
diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts
index 3836b6e9..78fcd816 100644
--- a/src/hooks/useMutation.ts
+++ b/src/hooks/useMutation.ts
@@ -5,6 +5,7 @@ import { Context } from '../context';
 import { OperationResult } from '../types';
 import { CombinedError, createRequest } from '../utils';
 import { useImmediateState } from './useImmediateState';
+import { useDevtoolsContext } from './useDevtoolsContext';
 
 export interface UseMutationState<T> {
   fetching: boolean;
@@ -20,6 +21,7 @@ export type UseMutationResponse<T, V> = [
 export const useMutation = <T = any, V = object>(
   query: DocumentNode | string
 ): UseMutationResponse<T, V> => {
+  const [devtoolsContext] = useDevtoolsContext();
   const client = useContext(Context);
   const [state, setState] = useImmediateState<UseMutationState<T>>({
     fetching: false,
@@ -34,7 +36,7 @@ export const useMutation = <T = any, V = object>(
       const request = createRequest(query, variables as any);
 
       return pipe(
-        client.executeMutation(request),
+        client.executeMutation(request, devtoolsContext),
         toPromise
       ).then(result => {
         const { data, error } = result;
@@ -42,7 +44,7 @@ export const useMutation = <T = any, V = object>(
         return result;
       });
     },
-    [client, query, setState]
+    [client, devtoolsContext, query, setState]
   );
 
   return [state, executeMutation];
diff --git a/src/hooks/useQuery.spec.ts b/src/hooks/useQuery.spec.ts
index e0db51e4..8875e357 100644
--- a/src/hooks/useQuery.spec.ts
+++ b/src/hooks/useQuery.spec.ts
@@ -90,9 +90,9 @@ describe('useQuery', () => {
         query: expect.any(Object),
         variables: mockVariables,
       },
-      {
+      expect.objectContaining({
         requestPolicy: undefined,
-      }
+      })
     );
   });
 
@@ -138,9 +138,9 @@ describe('useQuery', () => {
         query: expect.any(Object),
         variables: {},
       },
-      {
+      expect.objectContaining({
         requestPolicy: undefined,
-      }
+      })
     );
   });
 
@@ -168,9 +168,9 @@ describe('useQuery', () => {
         query: expect.any(Object),
         variables: newVariables,
       },
-      {
+      expect.objectContaining({
         requestPolicy: undefined,
-      }
+      })
     );
   });
 
@@ -211,9 +211,9 @@ describe('useQuery', () => {
         query: expect.any(Object),
         variables: mockVariables,
       },
-      {
+      expect.objectContaining({
         requestPolicy: 'cache-first',
-      }
+      })
     );
 
     rerender({
@@ -229,9 +229,9 @@ describe('useQuery', () => {
         query: expect.any(Object),
         variables: mockVariables,
       },
-      {
+      expect.objectContaining({
         requestPolicy: 'network-only',
-      }
+      })
     );
   });
 
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index 46233cc3..cb25c6ed 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -4,6 +4,7 @@ import { pipe, subscribe } from 'wonka';
 import { Context } from '../context';
 import { OperationContext, RequestPolicy } from '../types';
 import { CombinedError, noop } from '../utils';
+import { useDevtoolsContext } from './useDevtoolsContext';
 import { useRequest } from './useRequest';
 import { useImmediateEffect } from './useImmediateEffect';
 import { useImmediateState } from './useImmediateState';
@@ -29,6 +30,7 @@ export type UseQueryResponse<T> = [
 export const useQuery = <T = any, V = object>(
   args: UseQueryArgs<V>
 ): UseQueryResponse<T> => {
+  const [devtoolsContext] = useDevtoolsContext();
   const unsubscribe = useRef(noop);
   const client = useContext(Context);
 
@@ -54,23 +56,25 @@ export const useQuery = <T = any, V = object>(
         client.executeQuery(request, {
           requestPolicy: args.requestPolicy,
           ...opts,
+          ...devtoolsContext,
         }),
         subscribe(({ data, error }) => {
           setState({ fetching: false, data, error });
         })
       );
     },
-    [args.requestPolicy, client, request, setState]
+    [args.requestPolicy, client, devtoolsContext, request, setState]
   );
 
   useImmediateEffect(() => {
     if (args.pause) {
       unsubscribe.current();
-      return setState(s => ({ ...s, fetching: false }));
+      setState(s => ({ ...s, fetching: false }));
+      return noop;
     }
 
     executeQuery();
-    return () => unsubscribe.current();
+    return () => unsubscribe.current(); // eslint-disable-line
   }, [executeQuery, args.pause, setState]);
 
   return [state, executeQuery];
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index b5ca745d..42966481 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, useRef } from 'react';
 import { pipe, subscribe } from 'wonka';
 import { Context } from '../context';
 import { CombinedError, noop } from '../utils';
+import { useDevtoolsContext } from './useDevtoolsContext';
 import { useRequest } from './useRequest';
 import { useImmediateState } from './useImmediateState';
 
@@ -25,6 +26,7 @@ export const useSubscription = <T = any, R = T, V = object>(
   args: UseSubscriptionArgs<V>,
   handler?: SubscriptionHandler<T, R>
 ): UseSubscriptionResponse<R> => {
+  const [devtoolsContext] = useDevtoolsContext();
   const unsubscribe = useRef(noop);
   const client = useContext(Context);
 
@@ -42,7 +44,7 @@ export const useSubscription = <T = any, R = T, V = object>(
     unsubscribe.current();
 
     [unsubscribe.current] = pipe(
-      client.executeSubscription(request),
+      client.executeSubscription(request, devtoolsContext),
       subscribe(({ data, error }) => {
         setState(s => ({
           fetching: true,
@@ -51,14 +53,14 @@ export const useSubscription = <T = any, R = T, V = object>(
         }));
       })
     );
-  }, [client, handler, request, setState]);
+  }, [client, devtoolsContext, handler, request, setState]);
 
   // Trigger subscription on query change
   // We don't use useImmediateEffect here as we have no way of
   // unsubscribing from subscriptions during SSR
   useEffect(() => {
     executeSubscription();
-    return () => unsubscribe.current();
+    return () => unsubscribe.current(); // eslint-disable-line
   }, [executeSubscription]);
 
   return [state];
diff --git a/src/index.ts b/src/index.ts
index 5ce20da5..80a26fd7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,4 +4,4 @@ export * from './components';
 export * from './exchanges';
 export * from './hooks';
 export * from './types';
-export * from './utils';
+export { CombinedError, createRequest } from './utils';
diff --git a/src/types.ts b/src/types.ts
index 464b3c4c..5eb12b8a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -18,6 +18,9 @@ export type RequestPolicy =
   | 'network-only'
   | 'cache-and-network';
 
+/** How the operation has */
+export type CacheOutcome = 'miss' | 'partial' | 'hit';
+
 /** A Graphql query, mutation, or subscription. */
 export interface GraphQLRequest {
   /** Unique identifier of the request. */
@@ -32,6 +35,11 @@ export interface OperationContext {
   fetchOptions?: RequestInit | (() => RequestInit);
   requestPolicy: RequestPolicy;
   url: string;
+  meta?: {
+    source?: string;
+    cacheOutcome?: CacheOutcome;
+    networkLatency?: number;
+  };
 }
 
 /** A [query]{@link Query} or [mutation]{@link Mutation} with additional metadata for use during transmission. */
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 99f7b9aa..6021f177 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,9 +1,9 @@
-export { CombinedError } from './error';
-export { getKeyForRequest } from './keyForQuery';
-export { createRequest } from './request';
+export * from './error';
+export * from './keyForQuery';
+export * from './request';
 export * from './ssr';
-export { formatDocument, collectTypesFromResponse } from './typenames';
-export { toSuspenseSource } from './toSuspenseSource';
+export * from './typenames';
+export * from './toSuspenseSource';
 
 export const noop = () => {
   /* noop */
diff --git a/src/utils/request.ts b/src/utils/request.ts
index 0f01129c..a81f8763 100644
--- a/src/utils/request.ts
+++ b/src/utils/request.ts
@@ -1,7 +1,7 @@
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import { getKeyForRequest } from './keyForQuery';
-import { GraphQLRequest } from '../types';
+import { GraphQLRequest, Operation, OperationContext } from '../types';
 
 export const createRequest = (
   q: string | DocumentNode,
@@ -15,3 +15,18 @@ export const createRequest = (
     variables: vars || {},
   };
 };
+
+/** Spreads the provided metadata to the source operation's meta property in context.  */
+export const addMetadata = (
+  source: Operation,
+  meta: Exclude<OperationContext['meta'], undefined>
+) => ({
+  ...source,
+  context: {
+    ...source.context,
+    meta: {
+      ...source.context.meta,
+      ...meta,
+    },
+  },
+});
diff --git a/src/utils/typenames.ts b/src/utils/typenames.ts
index 2fa9cc2b..384b594f 100755
--- a/src/utils/typenames.ts
+++ b/src/utils/typenames.ts
@@ -35,25 +35,36 @@ export const collectTypesFromResponse = (response: object) =>
 
 const formatNode = (
   n: FieldNode | InlineFragmentNode | OperationDefinitionNode
-) =>
-  n.selectionSet !== undefined && n.selectionSet.selections !== undefined
-    ? {
-        ...n,
-        selectionSet: {
-          ...n.selectionSet,
-          selections: [
-            ...n.selectionSet.selections,
-            {
-              kind: 'Field',
-              name: {
-                kind: 'Name',
-                value: '__typename',
-              },
-            },
-          ],
+) => {
+  if (n.selectionSet === undefined) {
+    return false;
+  }
+
+  if (
+    n.selectionSet.selections.some(
+      s => s.kind === 'Field' && s.name.value === '__typename'
+    )
+  ) {
+    return n;
+  }
+
+  return {
+    ...n,
+    selectionSet: {
+      ...n.selectionSet,
+      selections: [
+        ...n.selectionSet.selections,
+        {
+          kind: 'Field',
+          name: {
+            kind: 'Name',
+            value: '__typename',
+          },
         },
-      }
-    : false;
+      ],
+    },
+  };
+};
 
 export const formatDocument = (astNode: DocumentNode) =>
   visit(astNode, {


Test patch:
diff --git a/src/components/Query.test.tsx b/src/components/Query.test.tsx
index fb523c4f..801fa4a0 100644
--- a/src/components/Query.test.tsx
+++ b/src/components/Query.test.tsx
@@ -20,10 +20,8 @@ jest.mock('../client', () => {
 import * as React from 'react';
 import { cleanup, render } from '@testing-library/react';
 import { Query } from './Query';
-import { createClient } from '../client';
 
 // @ts-ignore
-const client = createClient();
 const query = '{ example }';
 const variables = {
   myVar: 1234,
diff --git a/src/components/Subscription.test.tsx b/src/components/Subscription.test.tsx
index bc6d7414..8537107e 100644
--- a/src/components/Subscription.test.tsx
+++ b/src/components/Subscription.test.tsx
@@ -20,10 +20,7 @@ jest.mock('../client', () => {
 import * as React from 'react';
 import { cleanup, render } from '@testing-library/react';
 import { Subscription } from './Subscription';
-import { createClient } from '../client';
 
-// @ts-ignore
-const client = createClient() as { executeSubscription: jest.Mock };
 const query = 'subscription Example { example }';
 
 describe('Subscription', () => {
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index ad804f92..13a0174b 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`returns error data from fetch 1`] = `
+exports[`on error returns error data 1`] = `
 Object {
   "data": undefined,
   "error": [CombinedError: [Network] ],
@@ -9,6 +9,9 @@ Object {
       "fetchOptions": Object {
         "method": "POST",
       },
+      "meta": Object {
+        "networkLatency": 100,
+      },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
@@ -128,7 +131,7 @@ Object {
 }
 `;
 
-exports[`returns response data from fetch 1`] = `
+exports[`on success returns response data 1`] = `
 Object {
   "data": Object {
     "data": Object {
@@ -149,6 +152,9 @@ Object {
           },
         ],
       },
+      "meta": Object {
+        "networkLatency": 100,
+      },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
diff --git a/src/exchanges/cache.test.ts b/src/exchanges/cache.test.ts
index c3fd5cb3..09a44b07 100755
--- a/src/exchanges/cache.test.ts
+++ b/src/exchanges/cache.test.ts
@@ -1,4 +1,13 @@
-import { makeSubject, map, pipe, publish, Source, Subject } from 'wonka';
+import {
+  makeSubject,
+  map,
+  pipe,
+  publish,
+  Source,
+  Subject,
+  scan,
+  toPromise,
+} from 'wonka';
 import { Client } from '../client';
 import {
   mutationOperation,
@@ -9,7 +18,7 @@ import {
   subscriptionResult,
   undefinedQueryResponse,
 } from '../test-utils';
-import { Operation } from '../types';
+import { Operation, OperationResult } from '../types';
 import { afterMutation, cacheExchange } from './cache';
 
 let response;
@@ -42,76 +51,121 @@ beforeEach(() => {
   exchangeArgs = { forward, client };
 });
 
-it('forwards to next exchange when no cache is found', () => {
-  const [ops$, next, complete] = input;
-  const exchange = cacheExchange(exchangeArgs)(ops$);
+describe('on query', () => {
+  it('forwards to next exchange when no cache hit', () => {
+    const [ops$, next, complete] = input;
+    const exchange = cacheExchange(exchangeArgs)(ops$);
 
-  publish(exchange);
-  next(queryOperation);
-  complete();
-  expect(forwardedOperations.length).toBe(1);
-  expect(reexecuteOperation).not.toBeCalled();
-});
+    publish(exchange);
+    next(queryOperation);
+    complete();
+    expect(forwardedOperations.length).toBe(1);
+    expect(reexecuteOperation).not.toBeCalled();
+  });
+
+  it('caches results', () => {
+    const [ops$, next, complete] = input;
+    const exchange = cacheExchange(exchangeArgs)(ops$);
 
-it('caches queries', () => {
-  const [ops$, next, complete] = input;
-  const exchange = cacheExchange(exchangeArgs)(ops$);
+    publish(exchange);
+    next(queryOperation);
+    next(queryOperation);
+    complete();
+    expect(forwardedOperations.length).toBe(1);
+    expect(reexecuteOperation).not.toBeCalled();
+  });
 
-  publish(exchange);
-  next(queryOperation);
-  next(queryOperation);
-  complete();
-  expect(forwardedOperations.length).toBe(1);
-  expect(reexecuteOperation).not.toBeCalled();
-});
+  describe('cache hit', () => {
+    it('is miss when operation is forwarded', () => {
+      const [ops$, next, complete] = input;
+      const exchange = cacheExchange(exchangeArgs)(ops$);
+
+      publish(exchange);
+      next(queryOperation);
+      complete();
+
+      expect(forwardedOperations[0].context).toHaveProperty(
+        'meta.cacheOutcome',
+        'miss'
+      );
+    });
 
-it("doesn't cache mutations", () => {
-  response = mutationResponse;
-  const [ops$, next, complete] = input;
-  const exchange = cacheExchange(exchangeArgs)(ops$);
-
-  publish(exchange);
-  next(mutationOperation);
-  next(mutationOperation);
-  complete();
-  expect(forwardedOperations.length).toBe(2);
-  expect(reexecuteOperation).not.toBeCalled();
+    it('is true when cached response is returned', async () => {
+      const [ops$, next, complete] = input;
+      const exchange = cacheExchange(exchangeArgs)(ops$);
+
+      const results$ = pipe(
+        exchange,
+        scan((acc, x) => [...acc, x], [] as OperationResult[]),
+        toPromise
+      );
+
+      publish(exchange);
+      next(queryOperation);
+      next(queryOperation);
+      complete();
+
+      const results = await results$;
+      expect(results[1].operation.context).toHaveProperty(
+        'meta.cacheOutcome',
+        'hit'
+      );
+    });
+  });
 });
 
-it('retriggers query operation when mutation occurs', () => {
-  const typename = 'ExampleType';
-  const resultCache = new Map([[123, queryResponse]]);
-  const operationCache = { [typename]: new Set([123]) };
-
-  afterMutation(resultCache, operationCache, exchangeArgs.client)({
-    ...mutationResponse,
-    data: {
-      todos: [
-        {
-          id: 1,
-          __typename: typename,
-        },
-      ],
-    },
+describe('on mutation', () => {
+  it('does not cache', () => {
+    response = mutationResponse;
+    const [ops$, next, complete] = input;
+    const exchange = cacheExchange(exchangeArgs)(ops$);
+
+    publish(exchange);
+    next(mutationOperation);
+    next(mutationOperation);
+    complete();
+    expect(forwardedOperations.length).toBe(2);
+    expect(reexecuteOperation).not.toBeCalled();
   });
 
-  expect(reexecuteOperation).toBeCalledTimes(1);
+  it('retriggers query operation', () => {
+    const typename = 'ExampleType';
+    const resultCache = new Map([[123, queryResponse]]);
+    const operationCache = { [typename]: new Set([123]) };
+
+    afterMutation(resultCache, operationCache, exchangeArgs.client)({
+      ...mutationResponse,
+      data: {
+        todos: [
+          {
+            id: 1,
+            __typename: typename,
+          },
+        ],
+      },
+    });
+
+    expect(reexecuteOperation).toBeCalledTimes(1);
+  });
 });
 
-it('forwards subscriptions', () => {
-  response = subscriptionResult;
-  const [ops$, next, complete] = input;
-  const exchange = cacheExchange(exchangeArgs)(ops$);
-
-  publish(exchange);
-  next(subscriptionOperation);
-  next(subscriptionOperation);
-  complete();
-  expect(forwardedOperations.length).toBe(2);
-  expect(reexecuteOperation).not.toBeCalled();
+describe('on subscription', () => {
+  it('forwards subscriptions', () => {
+    response = subscriptionResult;
+    const [ops$, next, complete] = input;
+    const exchange = cacheExchange(exchangeArgs)(ops$);
+
+    publish(exchange);
+    next(subscriptionOperation);
+    next(subscriptionOperation);
+    complete();
+    expect(forwardedOperations.length).toBe(2);
+    expect(reexecuteOperation).not.toBeCalled();
+  });
 });
 
-describe('undefined data', () => {
+// Empty query response implies the data propertys is undefined
+describe('on empty query response', () => {
   beforeEach(() => {
     response = undefinedQueryResponse;
     forwardedOperations = [];
@@ -136,7 +190,7 @@ describe('undefined data', () => {
     exchangeArgs = { forward, client };
   });
 
-  it('should not cache on undefined result', () => {
+  it('does not cache response', () => {
     const [ops$, next, complete] = input;
     const exchange = cacheExchange(exchangeArgs)(ops$);
 
diff --git a/src/exchanges/fetch.test.ts b/src/exchanges/fetch.test.ts
index b809ae2f..fc7d30f0 100755
--- a/src/exchanges/fetch.test.ts
+++ b/src/exchanges/fetch.test.ts
@@ -40,69 +40,114 @@ const exchangeArgs = {
   client: {} as Client,
 };
 
-it('returns response data from fetch', async () => {
-  fetch.mockResolvedValue({
-    status: 200,
-    json: jest.fn().mockResolvedValue(response),
+describe('on success', () => {
+  beforeEach(() => {
+    jest
+      .spyOn(Date, 'now')
+      .mockImplementationOnce(() => 100)
+      .mockImplementationOnce(() => 200);
+
+    fetch.mockResolvedValue({
+      status: 200,
+      json: jest.fn().mockResolvedValue(response),
+    });
   });
 
-  const fetchOptions = jest.fn().mockReturnValue({});
-
-  const data = await pipe(
-    fromValue({
-      ...queryOperation,
-      context: {
-        ...queryOperation.context,
-        fetchOptions,
-      },
-    }),
-    fetchExchange(exchangeArgs),
-    toPromise
-  );
-
-  expect(data).toMatchSnapshot();
-  expect(fetchOptions).toHaveBeenCalled();
+  it('returns response data', async () => {
+    const fetchOptions = jest.fn().mockReturnValue({});
+
+    const data = await pipe(
+      fromValue({
+        ...queryOperation,
+        context: {
+          ...queryOperation.context,
+          fetchOptions,
+        },
+      }),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+    expect(fetchOptions).toHaveBeenCalled();
+  });
+
+  it('adds latency info to the context', async () => {
+    const data = await pipe(
+      fromValue({
+        ...queryOperation,
+        context: {
+          ...queryOperation.context,
+        },
+      }),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data.operation.context).toHaveProperty('meta.networkLatency', 100);
+  });
 });
 
-it('returns error data from fetch', async () => {
-  fetch.mockResolvedValue({
-    status: 400,
-    json: jest.fn().mockResolvedValue(response),
+describe('on error', () => {
+  beforeEach(() => {
+    jest
+      .spyOn(Date, 'now')
+      .mockImplementationOnce(() => 100)
+      .mockImplementationOnce(() => 200);
+
+    fetch.mockResolvedValue({
+      status: 400,
+      json: jest.fn().mockResolvedValue(response),
+    });
+  });
+
+  it('returns error data', async () => {
+    const data = await pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
   });
 
-  const data = await pipe(
-    fromValue(queryOperation),
-    fetchExchange(exchangeArgs),
-    toPromise
-  );
+  it('returns error data', async () => {
+    const data = await pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
 
-  expect(data).toMatchSnapshot();
+    expect(data.operation.context).toHaveProperty('meta.networkLatency', 100);
+  });
 });
 
-it('calls cancel when the Observable is cancelled', () => {
-  fetch.mockReturnValue(Promise.reject(abortError));
+describe('on teardown', () => {
+  it('aborts the outgoing request', () => {
+    fetch.mockReturnValue(Promise.reject(abortError));
 
-  const [unsubscribe] = pipe(
-    fromValue(queryOperation),
-    fetchExchange(exchangeArgs),
-    subscribe(fail)
-  );
+    const [unsubscribe] = pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      subscribe(fail)
+    );
 
-  unsubscribe();
-  expect(fetch).toHaveBeenCalledTimes(1);
-  expect(abort).toHaveBeenCalledTimes(1);
-});
+    unsubscribe();
+    expect(fetch).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
 
-it('should not start the operation when teardown gets passed', () => {
-  pipe(
-    fromValue({
-      ...queryOperation,
-      operationName: 'teardown' as OperationType,
-    }),
-    fetchExchange(exchangeArgs),
-    subscribe(fail)
-  );
-
-  expect(fetch).toHaveBeenCalledTimes(0);
-  expect(abort).toHaveBeenCalledTimes(0);
+  it('does not call the query', () => {
+    pipe(
+      fromValue({
+        ...queryOperation,
+        operationName: 'teardown' as OperationType,
+      }),
+      fetchExchange(exchangeArgs),
+      subscribe(fail)
+    );
+
+    expect(fetch).toHaveBeenCalledTimes(0);
+    expect(abort).toHaveBeenCalledTimes(0);
+  });
 });
diff --git a/src/hooks/useImmediateState.test.tsx b/src/hooks/useImmediateState.test.tsx
index 95ddf999..3dce45af 100644
--- a/src/hooks/useImmediateState.test.tsx
+++ b/src/hooks/useImmediateState.test.tsx
@@ -9,12 +9,13 @@ const initialState = { someObject: 1234 };
 const updateState = { someObject: 5678 };
 let state;
 let setState;
+let returnVal;
 
 const Fixture = ({ update }: { update?: boolean }) => {
   const [a, set] = useImmediateState<object>(initialState);
 
   if (update) {
-    set(updateState);
+    returnVal = set(updateState);
   }
 
   state = a;
@@ -35,6 +36,7 @@ describe('on initial mount', () => {
     renderer.create(<Fixture update={true} />);
     expect(setStateMock).toBeCalledTimes(0);
     expect(state).toEqual(updateState);
+    expect(returnVal).toBe(undefined);
   });
 });
 
@@ -43,5 +45,6 @@ describe('on later mounts', () => {
     renderer.create(<Fixture />);
     setState(updateState);
     expect(setStateMock).toBeCalledTimes(1);
+    expect(returnVal).toBe(undefined);
   });
 });
diff --git a/src/hooks/useImmediateState.ts b/src/hooks/useImmediateState.ts
index c1bc9bec..979d9524 100644
--- a/src/hooks/useImmediateState.ts
+++ b/src/hooks/useImmediateState.ts
@@ -15,17 +15,20 @@ export const useImmediateState = <S extends {}>(init: S): [S, SetState<S>] => {
   const [state, setState] = useState<S>(initialState.current);
 
   // This wraps setState and updates the state mutably on initial mount
-  const updateState: SetState<S> = useCallback((action: SetStateAction<S>) => {
-    if (!isMounted.current) {
-      const newValue =
-        typeof action === 'function'
-          ? (action as (arg: S) => S)(initialState.current)
-          : action;
-      return Object.assign(initialState.current, newValue);
-    }
-
-    setState(action);
-  }, []);
+  const updateState: SetState<S> = useCallback(
+    (action: SetStateAction<S>): void => {
+      if (!isMounted.current) {
+        const newValue =
+          typeof action === 'function'
+            ? (action as (arg: S) => S)(initialState.current)
+            : action;
+        Object.assign(initialState.current, newValue);
+      } else {
+        setState(action);
+      }
+    },
+    []
+  );
 
   !isSSR && // eslint-disable-next-line react-hooks/rules-of-hooks
     useLayoutEffect(() => {
diff --git a/src/hooks/useMutation.test.tsx b/src/hooks/useMutation.test.tsx
index 853136ab..321adf5a 100644
--- a/src/hooks/useMutation.test.tsx
+++ b/src/hooks/useMutation.test.tsx
@@ -106,6 +106,16 @@ describe('on execute', () => {
       vars
     );
   });
+
+  it('calls executeMutation with source component info', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars);
+    });
+    expect(client.executeMutation.mock.calls[0][1]).toHaveProperty('meta', {
+      source: 'MutationUser',
+    });
+  });
 });
 
 describe('on subscription update', () => {
diff --git a/src/hooks/useQuery.test.tsx b/src/hooks/useQuery.test.tsx
index 4cb69728..9fa87b27 100644
--- a/src/hooks/useQuery.test.tsx
+++ b/src/hooks/useQuery.test.tsx
@@ -83,9 +83,22 @@ describe('on initial useEffect', () => {
         query: expect.any(Object),
         variables: props.variables,
       },
-      {
+      expect.objectContaining({
         requestPolicy: undefined,
-      }
+      })
+    );
+  });
+
+  it('passes source component name to executeQuery', () => {
+    renderer.create(<QueryUser {...props} />);
+
+    expect(client.executeQuery).toBeCalledWith(
+      expect.any(Object),
+      expect.objectContaining({
+        meta: {
+          source: 'QueryUser',
+        },
+      })
     );
   });
 });
diff --git a/src/hooks/useSubscription.test.tsx b/src/hooks/useSubscription.test.tsx
index fb66e637..ca8efbf9 100644
--- a/src/hooks/useSubscription.test.tsx
+++ b/src/hooks/useSubscription.test.tsx
@@ -51,11 +51,22 @@ describe('on initial useEffect', () => {
 
   it('passes query to executeSubscription', () => {
     renderer.create(<SubscriptionUser q={query} />);
-    expect(client.executeSubscription).toBeCalledWith({
-      key: expect.any(Number),
-      query: expect.any(Object),
-      variables: {},
-    });
+    expect(client.executeSubscription).toBeCalledWith(
+      {
+        key: expect.any(Number),
+        query: expect.any(Object),
+        variables: {},
+      },
+      expect.any(Object)
+    );
+  });
+
+  it('passes source component info to executeSubscription', () => {
+    renderer.create(<SubscriptionUser q={query} />);
+    expect(client.executeSubscription).toBeCalledWith(
+      expect.any(Object),
+      expect.objectContaining({ meta: { source: 'SubscriptionUser' } })
+    );
   });
 });
 
diff --git a/src/utils/typenames.test.ts b/src/utils/typenames.test.ts
index e8b95a83..aadf1403 100755
--- a/src/utils/typenames.test.ts
+++ b/src/utils/typenames.test.ts
@@ -7,15 +7,37 @@ const formatTypeNames = (query: string) => {
 };
 
 describe('formatTypeNames', () => {
-  it('should add typenames to a query string', () => {
-    expect(formatTypeNames(`{ todos { id } }`)).toBe(`{
-  todos {
-    id
-    __typename
-  }
-  __typename
-}
-`);
+  it('adds typenames to a query string', () => {
+    expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(`
+      "{
+        todos {
+          id
+          __typename
+        }
+        __typename
+      }
+      "
+    `);
+  });
+
+  it('does not duplicate typenames', () => {
+    expect(
+      formatTypeNames(`{ 
+      todos { 
+        id
+      }
+      __typename
+    }`)
+    ).toMatchInlineSnapshot(`
+            "{
+              todos {
+                id
+                __typename
+              }
+              __typename
+            }
+            "
+        `);
   });
 });