Repository: FormidableLabs/urql
Pull request #351 (2019-07-23 09:38:01)

(feat) - allow passing operationContext to useQuery
Fixes: https://github.com/FormidableLabs/urql/issues/346

kitten: If there are no objections, I'd just like to merge this as is for now 😅 Potentially this could obviously be nicer, but since for most users `context` won't be passed, the simple solution may be best here. In other cases for "advanced" use-cases with `context`, I'd assume that people would spot the required memoisation rather quickly.

On this note though, we may want to add the same modification to `useMutation` and `useSubscription` as well:

- `useMutation`: Potentially an optional second argument on `executeMutation`? https://github.com/FormidableLabs/urql/blob/master/src/hooks/useMutation.ts#L35
- `useSubscription`: Possibly the exact same https://github.com/FormidableLabs/urql/blob/master/src/hooks/useSubscription.ts#L12
JoviDeCroock: Hmm, weird issue in the `useSubscription` test. When logging it calls with url but when checking the mock it doesn't seem to be present. Will look further after a break
JoviDeCroock: @kitten just updating docs atm and then I think it's ready to merge, is there a spot that should be safe to note about the context and memoization of it?
kitten: @JoviDeCroock I suppose we should add an entire section about the context somewhere, where we're also talking about customising urql. So maybe in "Extending & Experimenting". I'm thinking that doc can be extended with "FAQ-style" guides.

E.g.: "How to write custom exchanges", "How to extend the context", etc
JoviDeCroock: All right, added it thanks for the heads up. It's rather small now but we can iterate on this.
kitten: @jovidecroock let's discuss this tomorrow! There's a lot of todos for the docs anyway, so we can put down some specific todos for what sections need to be written 🙌

Patch:
diff --git a/docs/api.md b/docs/api.md
index ca102454..6042d40c 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -12,6 +12,7 @@ interface UseQueryArgs {
   variables?: any;
   requestPolicy?: RequestPolicy;
   pause?: boolean;
+  context?: Partial<OperationContext>;
 }
 ```
 
@@ -69,6 +70,7 @@ The options argument shape is:
 interface UseSubscriptionArgs {
   query: string;
   variables?: any;
+  context?: Partial<OperationContext>;
 }
 ```
 
@@ -103,6 +105,7 @@ interface UseSubscriptionState<T> {
 | ------------- | -------------------------- | ----------------------------------------------------------------------------------------------------- |
 | query         | `string`                   | The GraphQL request's query                                                                           |
 | variables     | `object`                   | The GraphQL request's variables                                                                       |
+| context       | `?object`                  | The GraphQL request's context                                                                         |
 | requestPolicy | `?RequestPolicy`           | An optional request policy that should be used                                                        |
 | pause         | `?boolean`                 | A boolean flag instructing `Query` to pause execution of the subsequent query operation               |
 | children      | `RenderProps => ReactNode` | A function that follows the typical render props pattern. The shape of the render props is as follows |
@@ -148,6 +151,7 @@ interface UseSubscriptionState<T> {
 | --------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
 | query     | `string`                                            | The GraphQL subscription's query                                                                      |
 | variables | `object`                                            | The GraphQL subscriptions' variables                                                                  |
+| context   | `?object`                                           | The GraphQL subscriptions' context                                                                    |
 | handler   | `undefined \| (prev: R \| undefined, data: T) => R` | The handler that should combine/update the subscription's data with incoming data                     |
 | children  | `RenderProps => ReactNode`                          | A function that follows the typical render props pattern. The shape of the render props is as follows |
 
@@ -177,6 +181,7 @@ It accepts a bunch of inputs when it's created
 | ------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
 | url          | `string`                           | The GraphQL API URL as used by `fetchExchange`                                                                  |
 | fetchOptions | `RequestInit \| () => RequestInit` | Additional `fetchOptions` that `fetch` in `fetchExchange` should use to make a request                          |
+| fetch        | `typeof fetch`                     | An alternative implementation of `fetch` that will be used by the `fetchExchange` instead of `window.fetch`     |
 | suspense     | `?boolean`                         | Activates the experimental React suspense mode, which can be used during server-side rendering to prefetch data |
 | exchanges    | `Exchange[]`                       | An array of `Exchange`s that the client should use instead of the list of `defaultExchanges`                    |
 
diff --git a/docs/extending-and-experimenting.md b/docs/extending-and-experimenting.md
index 1e18cfa7..d2883f45 100644
--- a/docs/extending-and-experimenting.md
+++ b/docs/extending-and-experimenting.md
@@ -148,3 +148,10 @@ subscribe to the result of `executeQuery`, keep track
 of the unsubscription (`teardown`) and update
 some state. This all can be reapplied when you write your
 own APIs.
+
+## FAQ
+
+My component/hooks keeps triggering rerenders when passing in `context`
+
+- When not memoizing `context` this will always trigger refetches,
+  this can be done through the `useMemo` hook.
diff --git a/src/client.ts b/src/client.ts
index 75827692..e414628d 100755
--- a/src/client.ts
+++ b/src/client.ts
@@ -33,6 +33,8 @@ export interface ClientOptions {
   url: string;
   /** Any additional options to pass to fetch. */
   fetchOptions?: RequestInit | (() => RequestInit);
+  /** An alternative fetch implementation. */
+  fetch?: typeof fetch;
   /** An ordered array of Exchanges. */
   exchanges?: Exchange[];
   /** Activates support for Suspense. */
@@ -49,6 +51,7 @@ export const createClient = (opts: ClientOptions) => new Client(opts);
 export class Client {
   // These are variables derived from ClientOptions
   url: string;
+  fetch?: typeof fetch;
   fetchOptions?: RequestInit | (() => RequestInit);
   exchange: Exchange;
   suspense: boolean;
@@ -62,6 +65,7 @@ export class Client {
   constructor(opts: ClientOptions) {
     this.url = opts.url;
     this.fetchOptions = opts.fetchOptions;
+    this.fetch = opts.fetch;
     this.suspense = !!opts.suspense;
 
     // This subject forms the input of operations; executeOperation may be
@@ -112,6 +116,7 @@ export class Client {
     return {
       url: this.url,
       fetchOptions: this.fetchOptions,
+      fetch: this.fetch,
       ...opts,
       requestPolicy,
     };
diff --git a/src/components/Mutation.ts b/src/components/Mutation.ts
index a8a4a6c5..dd85e5fe 100644
--- a/src/components/Mutation.ts
+++ b/src/components/Mutation.ts
@@ -1,6 +1,6 @@
 import { ReactElement } from 'react';
 import { DocumentNode } from 'graphql';
-import { OperationResult } from '../types';
+import { OperationResult, OperationContext } from '../types';
 import { useMutation, UseMutationState } from '../hooks';
 
 export interface MutationProps<T, V> {
@@ -9,7 +9,10 @@ export interface MutationProps<T, V> {
 }
 
 export interface MutationState<T, V> extends UseMutationState<T> {
-  executeMutation: (variables?: V) => Promise<OperationResult<T>>;
+  executeMutation: (
+    variables?: V,
+    context?: Partial<OperationContext>
+  ) => Promise<OperationResult<T>>;
 }
 
 export function Mutation<T = any, V = any>({
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index f8ff9c1f..cc381855 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -76,6 +76,7 @@ const createFetchSource = (operation: Operation) => {
     };
 
     const startTime = Date.now();
+
     executeFetch(operation, fetchOptions).then(result => {
       if (result !== undefined) {
         next({
@@ -99,13 +100,13 @@ const createFetchSource = (operation: Operation) => {
 
 const executeFetch = (operation: Operation, opts: RequestInit) => {
   let response: Response | undefined;
-  const { url } = operation.context;
+  const { url, fetch: fetcher } = operation.context;
 
-  return fetch(url, opts)
+  return (fetcher || fetch)(url, opts)
     .then(res => {
       response = res;
-      checkStatus(opts.redirect, response);
-      return response.json();
+      checkStatus(opts.redirect, res);
+      return res.json();
     })
     .then(result => ({
       operation,
diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts
index 054f2a69..63f2abea 100644
--- a/src/hooks/useMutation.ts
+++ b/src/hooks/useMutation.ts
@@ -2,7 +2,7 @@ import { DocumentNode } from 'graphql';
 import { useContext, useCallback } from 'react';
 import { pipe, toPromise } from 'wonka';
 import { Context } from '../context';
-import { OperationResult } from '../types';
+import { OperationResult, OperationContext } from '../types';
 import { CombinedError, createRequest } from '../utils';
 import { useImmediateState } from './useImmediateState';
 import { useDevtoolsContext } from './useDevtoolsContext';
@@ -32,7 +32,7 @@ export const useMutation = <T = any, V = object>(
   });
 
   const executeMutation = useCallback(
-    (variables?: V) => {
+    (variables?: V, context?: Partial<OperationContext>) => {
       setState({
         fetching: true,
         error: undefined,
@@ -43,7 +43,7 @@ export const useMutation = <T = any, V = object>(
       const request = createRequest(query, variables as any);
 
       return pipe(
-        client.executeMutation(request, devtoolsContext),
+        client.executeMutation(request, { ...devtoolsContext, ...context }),
         toPromise
       ).then(result => {
         const { data, error, extensions } = result;
diff --git a/src/hooks/useQuery.spec.ts b/src/hooks/useQuery.spec.ts
index 693e5a57..8236254d 100644
--- a/src/hooks/useQuery.spec.ts
+++ b/src/hooks/useQuery.spec.ts
@@ -66,6 +66,27 @@ describe('useQuery', () => {
     });
   });
 
+  it('should support setting context in useQuery params', () => {
+    const context = { url: 'test' };
+    renderHook(
+      ({ query, variables }) => useQuery({ query, variables, context }),
+      { initialProps: { query: mockQuery, variables: mockVariables } }
+    );
+
+    expect(client.executeQuery).toBeCalledWith(
+      {
+        key: expect.any(Number),
+        query: expect.any(Object),
+        variables: mockVariables,
+      },
+      {
+        meta: { source: 'TestHook' },
+        requestPolicy: undefined,
+        url: 'test',
+      }
+    );
+  });
+
   it('should execute the subscription', async () => {
     const { waitForNextUpdate } = renderHook(
       ({ query, variables }) => useQuery({ query, variables }),
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index 82b7eba2..767247be 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -13,6 +13,7 @@ export interface UseQueryArgs<V> {
   query: string | DocumentNode;
   variables?: V;
   requestPolicy?: RequestPolicy;
+  context?: Partial<OperationContext>;
   pause?: boolean;
 }
 
@@ -57,6 +58,7 @@ export const useQuery = <T = any, V = object>(
       [unsubscribe.current] = pipe(
         client.executeQuery(request, {
           requestPolicy: args.requestPolicy,
+          ...args.context,
           ...opts,
           ...devtoolsContext,
         }),
@@ -65,7 +67,14 @@ export const useQuery = <T = any, V = object>(
         })
       );
     },
-    [args.requestPolicy, client, devtoolsContext, request, setState]
+    [
+      args.context,
+      args.requestPolicy,
+      client,
+      devtoolsContext,
+      request,
+      setState,
+    ]
   );
 
   useImmediateEffect(() => {
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index 8ccb6c15..25ea6843 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -6,10 +6,12 @@ import { CombinedError, noop } from '../utils';
 import { useDevtoolsContext } from './useDevtoolsContext';
 import { useRequest } from './useRequest';
 import { useImmediateState } from './useImmediateState';
+import { OperationContext } from '../types';
 
 export interface UseSubscriptionArgs<V> {
   query: DocumentNode | string;
   variables?: V;
+  context?: Partial<OperationContext>;
 }
 
 export type SubscriptionHandler<T, R> = (prev: R | undefined, data: T) => R;
@@ -46,7 +48,10 @@ export const useSubscription = <T = any, R = T, V = object>(
     unsubscribe.current();
 
     [unsubscribe.current] = pipe(
-      client.executeSubscription(request, devtoolsContext),
+      client.executeSubscription(request, {
+        ...devtoolsContext,
+        ...args.context,
+      }),
       subscribe(({ data, error, extensions }) => {
         setState(s => ({
           fetching: true,
@@ -56,7 +61,7 @@ export const useSubscription = <T = any, R = T, V = object>(
         }));
       })
     );
-  }, [client, devtoolsContext, handler, request, setState]);
+  }, [client, devtoolsContext, handler, request, setState, args.context]);
 
   // Trigger subscription on query change
   // We don't use useImmediateEffect here as we have no way of
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 6021f177..49861b08 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,7 +1,6 @@
 export * from './error';
 export * from './keyForQuery';
 export * from './request';
-export * from './ssr';
 export * from './typenames';
 export * from './toSuspenseSource';
 
diff --git a/src/utils/ssr.ts b/src/utils/ssr.ts
deleted file mode 100644
index 9eef157d..00000000
--- a/src/utils/ssr.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const isSSR =
-  typeof window === 'undefined' || !('HTMLElement' in window);


Test patch:
diff --git a/src/__snapshots__/client.test.ts.snap b/src/__snapshots__/client.test.ts.snap
index cff7bd7e..4885bab4 100644
--- a/src/__snapshots__/client.test.ts.snap
+++ b/src/__snapshots__/client.test.ts.snap
@@ -10,6 +10,7 @@ Client {
   "executeMutation": [Function],
   "executeQuery": [Function],
   "executeSubscription": [Function],
+  "fetch": undefined,
   "fetchOptions": undefined,
   "operations$": [Function],
   "reexecuteOperation": [Function],
diff --git a/src/__snapshots__/context.test.ts.snap b/src/__snapshots__/context.test.ts.snap
index 64a38f79..3a40f8a5 100644
--- a/src/__snapshots__/context.test.ts.snap
+++ b/src/__snapshots__/context.test.ts.snap
@@ -23,6 +23,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
+      "fetch": undefined,
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
@@ -39,6 +40,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
+      "fetch": undefined,
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
@@ -74,6 +76,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
+      "fetch": undefined,
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
@@ -90,6 +93,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
+      "fetch": undefined,
       "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
diff --git a/src/hooks/useImmediateState.ts b/src/hooks/useImmediateState.ts
index 979d9524..701a2aa1 100644
--- a/src/hooks/useImmediateState.ts
+++ b/src/hooks/useImmediateState.ts
@@ -1,9 +1,18 @@
-import { useRef, useState, useCallback, useLayoutEffect } from 'react';
-import { isSSR } from '../utils';
+import {
+  useRef,
+  useState,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+} from 'react';
 
 type SetStateAction<S> = S | ((prevState: S) => S);
 type SetState<S> = (action: SetStateAction<S>) => void;
 
+// See https://github.com/reduxjs/react-redux/blob/316467a/src/hooks/useSelector.js#L6-L15
+const useIsomorphicLayoutEffect =
+  typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
 /**
  * This is a drop-in replacement for useState, limited to object-based state.
  * During initial mount it will mutably update the state, instead of scheduling
@@ -11,32 +20,31 @@ type SetState<S> = (action: SetStateAction<S>) => void;
  */
 export const useImmediateState = <S extends {}>(init: S): [S, SetState<S>] => {
   const isMounted = useRef(false);
-  const initialState = useRef<S>({ ...init });
-  const [state, setState] = useState<S>(initialState.current);
+  const [state, setState] = useState<S>(init);
 
   // This wraps setState and updates the state mutably on initial mount
   const updateState: SetState<S> = useCallback(
     (action: SetStateAction<S>): void => {
       if (!isMounted.current) {
-        const newValue =
+        const newState =
           typeof action === 'function'
-            ? (action as (arg: S) => S)(initialState.current)
+            ? (action as (arg: S) => S)(state)
             : action;
-        Object.assign(initialState.current, newValue);
+        Object.assign(state, newState);
       } else {
         setState(action);
       }
     },
+    // eslint-disable-next-line react-hooks/exhaustive-deps
     []
   );
 
-  !isSSR && // eslint-disable-next-line react-hooks/rules-of-hooks
-    useLayoutEffect(() => {
-      isMounted.current = true;
-      return () => {
-        isMounted.current = false;
-      };
-    }, []);
+  useIsomorphicLayoutEffect(() => {
+    isMounted.current = true;
+    return () => {
+      isMounted.current = false;
+    };
+  }, []);
 
   return [state, updateState];
 };
diff --git a/src/hooks/useMutation.test.tsx b/src/hooks/useMutation.test.tsx
index 4e9fbc31..235add9d 100644
--- a/src/hooks/useMutation.test.tsx
+++ b/src/hooks/useMutation.test.tsx
@@ -116,6 +116,14 @@ describe('on execute', () => {
       source: 'MutationUser',
     });
   });
+
+  it('can adjust context in executeMutation', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars, { url: 'test' });
+    });
+    expect(client.executeMutation.mock.calls[0][1].url).toBe('test');
+  });
 });
 
 describe('on subscription update', () => {
diff --git a/src/hooks/useSubscription.test.tsx b/src/hooks/useSubscription.test.tsx
index ca8efbf9..6d043069 100644
--- a/src/hooks/useSubscription.test.tsx
+++ b/src/hooks/useSubscription.test.tsx
@@ -14,10 +14,11 @@ jest.mock('../client', () => {
 });
 
 import React, { FC } from 'react';
-import renderer from 'react-test-renderer';
+import renderer, { act } from 'react-test-renderer';
 // @ts-ignore - data is imported from mock only
 import { createClient, data } from '../client';
 import { useSubscription } from './useSubscription';
+import { OperationContext } from '../types';
 
 // @ts-ignore
 const client = createClient() as { executeSubscription: jest.Mock };
@@ -27,8 +28,9 @@ let state: any;
 const SubscriptionUser: FC<{
   q: string;
   handler?: (prev: any, data: any) => any;
-}> = ({ q, handler }) => {
-  const [s] = useSubscription({ query: q }, handler);
+  context?: Partial<OperationContext>;
+}> = ({ q, handler, context }) => {
+  const [s] = useSubscription({ query: q, context }, handler);
   state = s;
   return <p>{s.data}</p>;
 };
@@ -70,6 +72,24 @@ describe('on initial useEffect', () => {
   });
 });
 
+it('should support setting context in useSubscription params', () => {
+  const context = { url: 'test' };
+  act(() => {
+    renderer.create(<SubscriptionUser q={query} context={context} />);
+  });
+  expect(client.executeSubscription).toBeCalledWith(
+    {
+      key: expect.any(Number),
+      query: expect.any(Object),
+      variables: {},
+    },
+    {
+      url: 'test',
+      meta: { source: 'SubscriptionUser' },
+    }
+  );
+});
+
 describe('on subscription', () => {
   it('forwards client response', () => {
     const wrapper = renderer.create(<SubscriptionUser q={query} />);