Repository: FormidableLabs/urql (ts)

Issue:
Add metadata capabilities to urql
In order to expose enough information to create valuable devtools I'm proposing we add a _meta_ tag to our operations.

This tag can be set/updated at the following points:
 - on calling hooks / client execution
 - inside hooks (for sourcing the calling component)
 - inside any exchange

We could use context but I think keeping it separate (such as a property within context as @kitten has suggested) would be best as any information added to the meta property of an operation should not be mission critical (i.e. no worry of breaking anything at runtime).

**tl;dr:** add meta property to operations

Edit: clear up wording on using context

Pull request:
Add component source info to hooks
Hooks now add metadata information for devtools including the source component funciton name.

> Note: Because we're using the stack trace to determine component names, the sourced component name is considered to be it's function name - **not** the function's displayName property.

Fix #291

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 19a2ba6b..ceaf986d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,25 @@ 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,
+where the initial state would be incorrect after a fast response from
+the GraphQL API. This used to lock the state into `fetching: true`
+indefinitely in some cases.
+
+- Export all TS types for components (see [#312](https://github.com/FormidableLabs/urql/pull/312))
+- ⚠️ Fix state getting stuck on initial mount for fast responses (see [#310](https://github.com/FormidableLabs/urql/pull/310))
+- Refactor build tooling to be driven only by Rollup (see [#306](https://github.com/FormidableLabs/urql/pull/306))
+- Remove dev-only dependencies from `dependencies` (see [#304](https://github.com/FormidableLabs/urql/pull/304))
+
 ## [v1.1.1](https://github.com/FormidableLabs/urql/compare/v1.1.0...v1.1.1)
 
 This release comes with two small patches. One being a crticial fix,
diff --git a/package.json b/package.json
index e31ef4e6..b6f8bcd0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "urql",
-  "version": "1.1.1",
+  "version": "1.1.3",
   "description": "A highly customizable and versatile GraphQL client for React",
   "repository": "https://www.github.com/FormidableLabs/urql",
   "bugs": {
@@ -83,7 +83,9 @@
     "@babel/core": "^7.4.5",
     "@babel/plugin-transform-object-assign": "^7.2.0",
     "@babel/plugin-transform-react-jsx": "^7.3.0",
+    "@testing-library/react": "^8.0.1",
     "@types/enzyme": "3.1.16",
+    "@types/fast-json-stable-stringify": "^2.0.0",
     "@types/graphql": "^14.0.7",
     "@types/jest": "^23.3.13",
     "@types/react": "16.8.2",
@@ -132,8 +134,6 @@
     "react-dom": ">= 16.8.0"
   },
   "dependencies": {
-    "@testing-library/react": "^8.0.1",
-    "@types/fast-json-stable-stringify": "^2.0.0",
     "fast-json-stable-stringify": "^2.0.0",
     "graphql-tag": "^2.10.1",
     "prop-types": "^15.6.0",
diff --git a/src/components/index.ts b/src/components/index.ts
index b2d2dbd2..ece4da96 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,3 +1,3 @@
-export { Mutation } from './Mutation';
-export { Query } from './Query';
-export { Subscription } from './Subscription';
+export * from './Mutation';
+export * from './Query';
+export * from './Subscription';
diff --git a/src/global.d.ts b/src/global.d.ts
deleted file mode 100644
index 529c38a5..00000000
--- a/src/global.d.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-declare module 'create-react-context' {
-  import * as React from 'react';
-
-  export default function createReactContext<T>(
-    defaultValue: T
-  ): React.Context<T>;
-
-  export type Context<T> = React.Context<T>;
-
-  export interface ProviderProps<T> {
-    value: T;
-    children: React.ReactNode;
-  }
-
-  export interface ConsumerProps<T> {
-    children: (value: T) => React.ReactNode;
-  }
-}
diff --git a/src/hooks/useDevtoolsContext.ts b/src/hooks/useDevtoolsContext.ts
new file mode 100644
index 00000000..c7b57c2c
--- /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(() => [{ devtools: { 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/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 */


Test patch:
diff --git a/src/components/Mutation.test.tsx b/src/components/Mutation.test.tsx
index 8f309f6d..c1a6fb15 100644
--- a/src/components/Mutation.test.tsx
+++ b/src/components/Mutation.test.tsx
@@ -73,6 +73,6 @@ describe('Mutation', () => {
     setTimeout(() => {
       expect(props).toStrictEqual({ data: 1, fetching: false, error: 2 });
       done();
-    }, 200);
+    }, 400);
   });
 });
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/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..5cffa0db 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('devtools', {
+      source: 'MutationUser',
+    });
+  });
 });
 
 describe('on subscription update', () => {
diff --git a/src/hooks/useQuery.test.tsx b/src/hooks/useQuery.test.tsx
index 4cb69728..eb5cc4d3 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({
+        devtools: {
+          source: 'QueryUser',
+        },
+      })
     );
   });
 });
diff --git a/src/hooks/useSubscription.test.tsx b/src/hooks/useSubscription.test.tsx
index fb66e637..3ab36f77 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({ devtools: { source: 'SubscriptionUser' } })
+    );
   });
 });