Repository: FormidableLabs/urql
Pull request #178 (2019-02-14 20:51:33)

Add requestPolicy to manage cache strategy
This adds `requestPolicy` as a property on operation contexts. It has four modes, like in Apollo:

- `cache-first`: The default. It skips the network request (i.e. the other exchanges) when a result can be retrieved from the cache.
- `cache-only`: It returns an empty result when the data is not cached but also skips the network request. (With a normalised cache this would return a partial result if possible)
- `network-only`: This skips the cache entirely and forwards the request on to the other exchanges.
- `cache-and-network`: If data is cached it is first returned but another operation is then triggered using `network-only`, effectively using the cache and then updating it.

This can now be used as a prop on `useQuery`:

```js
const [res, executeQuery] = useQuery({ /* ... */ requestPolicy: 'network-only' });
```

But it can also be used to replace our clunky `refetch` that hasn't been reimplemented yet:

```js
const [res, executeQuery] = useQuery({ /* ... */ });
/* ... */
executeQuery({ requestPolicy: 'network-only' })
```

It can also be used on the `<Query>` component which now also has an `executeQuery` method
on the render prop arguments.


Patch:
diff --git a/examples/1-getting-started/src/app/Home.tsx b/examples/1-getting-started/src/app/Home.tsx
index 2326be66..08c02dbc 100644
--- a/examples/1-getting-started/src/app/Home.tsx
+++ b/examples/1-getting-started/src/app/Home.tsx
@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 import { useQuery } from 'urql';
 import { Error, Loading, Todo } from './components';
 
@@ -11,6 +11,10 @@ interface QueryResponse {
 
 export const Home: FC = () => {
   const [query, executeQuery] = useQuery<QueryResponse>({ query: TodoQuery });
+  const refetch = useCallback(
+    () => executeQuery({ requestPolicy: 'network-only' }),
+    []
+  );
 
   const getContent = () => {
     if (query.fetching || query.data === undefined) {
@@ -33,7 +37,7 @@ export const Home: FC = () => {
   return (
     <>
       {getContent()}
-      <button onClick={executeQuery}>Refetch</button>
+      <button onClick={refetch}>Refetch</button>
     </>
   );
 };
diff --git a/src/client.ts b/src/client.ts
index 14585e4a..66ee3f33 100755
--- a/src/client.ts
+++ b/src/client.ts
@@ -90,11 +90,16 @@ export class Client {
 
   private createOperationContext = (
     opts?: Partial<OperationContext>
-  ): OperationContext => ({
-    url: this.url,
-    fetchOptions: this.fetchOptions,
-    ...opts,
-  });
+  ): OperationContext => {
+    const { requestPolicy = 'cache-first' } = opts || {};
+
+    return {
+      url: this.url,
+      fetchOptions: this.fetchOptions,
+      ...opts,
+      requestPolicy,
+    };
+  };
 
   private createRequestOperation = (
     type: OperationType,
diff --git a/src/components/Query.tsx b/src/components/Query.tsx
index 903fd2d6..57740bbe 100644
--- a/src/components/Query.tsx
+++ b/src/components/Query.tsx
@@ -2,12 +2,14 @@ import React, { Component, FC, ReactNode } from 'react';
 import { pipe, subscribe } from 'wonka';
 import { Client } from '../client';
 import { Consumer } from '../context';
-import { CombinedError, createQuery } from '../utils';
+import { OperationContext, RequestPolicy } from '../types';
+import { CombinedError, createQuery, noop } from '../utils';
 
 interface QueryHandlerProps {
   client: Client;
   query: string;
   variables: object;
+  requestPolicy?: RequestPolicy;
   children: (arg: QueryHandlerState) => ReactNode;
 }
 
@@ -15,41 +17,13 @@ interface QueryHandlerState {
   fetching: boolean;
   data?: any;
   error?: CombinedError;
+  executeQuery: (opts?: Partial<OperationContext>) => void;
 }
 
 class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
-  private unsubscribe?: () => void;
+  private unsubscribe = noop;
 
-  public state = {
-    fetching: false,
-  };
-
-  public componentDidMount() {
-    this.executeQuery();
-  }
-
-  public componentDidUpdate(oldProps) {
-    if (
-      this.props.query === oldProps.query &&
-      this.props.variables === oldProps.variables
-    ) {
-      return;
-    }
-
-    this.executeQuery();
-  }
-
-  public componentWillUnmount() {
-    if (this.unsubscribe !== undefined) {
-      this.unsubscribe();
-    }
-  }
-
-  public render() {
-    return this.props.children(this.state);
-  }
-
-  private executeQuery() {
+  executeQuery = () => {
     if (this.unsubscribe !== undefined) {
       this.unsubscribe();
     }
@@ -62,10 +36,12 @@ class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
       fetching: true,
     });
 
-    const [unsubscribe] = pipe(
-      this.props.client.executeQuery(
-        createQuery(this.props.query, this.props.variables)
-      ),
+    const request = createQuery(this.props.query, this.props.variables);
+
+    const [teardown] = pipe(
+      this.props.client.executeQuery(request, {
+        requestPolicy: this.props.requestPolicy,
+      }),
       subscribe(({ data, error }) => {
         this.setState({
           fetching: false,
@@ -75,7 +51,35 @@ class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
       })
     );
 
-    this.unsubscribe = unsubscribe;
+    this.unsubscribe = teardown;
+  };
+
+  state = {
+    executeQuery: this.executeQuery,
+    fetching: false,
+  };
+
+  componentDidMount() {
+    this.executeQuery();
+  }
+
+  componentDidUpdate(oldProps) {
+    if (
+      this.props.query === oldProps.query &&
+      this.props.variables === oldProps.variables
+    ) {
+      return;
+    }
+
+    this.executeQuery();
+  }
+
+  componentWillUnmount() {
+    this.unsubscribe();
+  }
+
+  render() {
+    return this.props.children(this.state);
   }
 }
 
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 55d56de9..245ed8d4 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -27,8 +27,18 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
 
   const handleAfterQuery = afterQuery(resultCache, operationCache);
 
-  const isOperationCached = operation =>
-    operation.operationName === 'query' && resultCache.has(operation.key);
+  const isOperationCached = operation => {
+    const {
+      key,
+      operationName,
+      context: { requestPolicy },
+    } = operation;
+    return (
+      operationName === 'query' &&
+      requestPolicy !== 'network-only' &&
+      (requestPolicy === 'cache-only' || resultCache.has(key))
+    );
+  };
 
   const shouldSkip = (operation: Operation) =>
     operation.operationName !== 'mutation' &&
@@ -40,9 +50,25 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
     const cachedOps$ = pipe(
       sharedOps$,
       filter(op => !shouldSkip(op) && isOperationCached(op)),
-      map(op => {
-        // OperationResult is guaranteed to exist
-        return resultCache.get(op.key) as OperationResult;
+      map(operation => {
+        const {
+          key,
+          context: { requestPolicy },
+        } = operation;
+        const cachedResult = resultCache.get(key);
+        if (requestPolicy === 'cache-and-network') {
+          reexecuteOperation(client, operation);
+        }
+
+        if (cachedResult !== undefined) {
+          return cachedResult;
+        }
+
+        return {
+          operation,
+          data: undefined,
+          error: undefined,
+        };
       })
     );
 
@@ -70,6 +96,17 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
   };
 };
 
+// Reexecutes a given operation with the default requestPolicy
+const reexecuteOperation = (client: Client, operation: Operation) => {
+  return client.reexecuteOperation({
+    ...operation,
+    context: {
+      ...operation.context,
+      requestPolicy: 'network-only',
+    },
+  });
+};
+
 // Invalidates the cache given a mutation's response
 export const afterMutation = (
   resultCache: ResultCache,
@@ -88,7 +125,7 @@ export const afterMutation = (
   pendingOperations.forEach(key => {
     const operation = (resultCache.get(key) as OperationResult).operation; // Result is guaranteed
     resultCache.delete(key);
-    client.reexecuteOperation(operation);
+    reexecuteOperation(client, operation);
   });
 };
 
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index fecbb428..e7a6c913 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -1,11 +1,13 @@
 import { useContext, useEffect, useState } from 'react';
 import { pipe, subscribe } from 'wonka';
 import { Context } from '../context';
-import { CombinedError, createQuery } from '../utils';
+import { OperationContext, RequestPolicy } from '../types';
+import { CombinedError, createQuery, noop } from '../utils';
 
 interface UseQueryArgs {
   query: string;
   variables?: any;
+  requestPolicy?: RequestPolicy;
 }
 
 interface UseQueryState<T> {
@@ -14,10 +16,14 @@ interface UseQueryState<T> {
   error?: CombinedError;
 }
 
-type UseQueryResponse<T> = [UseQueryState<T>, () => void];
+type UseQueryResponse<T> = [
+  UseQueryState<T>,
+  (opts?: Partial<OperationContext>) => void
+];
 
 export const useQuery = <T = any>(args: UseQueryArgs): UseQueryResponse<T> => {
-  let unsubscribe: () => void | undefined;
+  let unsubscribe = noop;
+
   const client = useContext(Context);
   const [state, setState] = useState<UseQueryState<T>>({
     fetching: false,
@@ -25,25 +31,25 @@ export const useQuery = <T = any>(args: UseQueryArgs): UseQueryResponse<T> => {
     data: undefined,
   });
 
-  const executeUnsubscribe = () => {
-    if (unsubscribe !== undefined) {
-      unsubscribe();
-    }
-  };
-
-  const executeQuery = () => {
-    executeUnsubscribe();
+  const executeQuery = (opts?: Partial<OperationContext>) => {
+    unsubscribe();
     setState(s => ({ ...s, fetching: true }));
 
-    unsubscribe = pipe(
-      client.executeQuery(createQuery(args.query, args.variables)),
+    const request = createQuery(args.query, args.variables);
+    const [teardown] = pipe(
+      client.executeQuery(request, {
+        requestPolicy: args.requestPolicy,
+        ...opts,
+      }),
       subscribe(({ data, error }) => setState({ fetching: false, data, error }))
-    )[0];
+    );
+
+    unsubscribe = teardown;
   };
 
   useEffect(() => {
     executeQuery();
-    return () => executeUnsubscribe();
+    return unsubscribe;
   }, [args.query, args.variables]);
 
   // executeQuery === refetch
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index b3b5ac9c..210ef894 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -1,7 +1,7 @@
 import { useContext, useEffect, useState } from 'react';
 import { pipe, subscribe } from 'wonka';
 import { Context } from '../context';
-import { CombinedError, createQuery } from '../utils';
+import { CombinedError, createQuery, noop } from '../utils';
 
 interface UseSubscriptionArgs {
   query: string;
@@ -21,23 +21,18 @@ export const useSubscription = <T = any, R = T>(
   args: UseSubscriptionArgs,
   handler?: SubscriptionHandler<T, R>
 ): UseSubscriptionResponse<R> => {
-  let unsubscribe: () => void | undefined;
+  let unsubscribe = noop;
+
   const client = useContext(Context);
   const [state, setState] = useState<UseSubscriptionState<R>>({
     error: undefined,
     data: undefined,
   });
 
-  const executeUnsubscribe = () => {
-    if (unsubscribe !== undefined) {
-      unsubscribe();
-    }
-  };
-
   const executeSubscription = () => {
-    executeUnsubscribe();
+    unsubscribe();
 
-    unsubscribe = pipe(
+    const [teardown] = pipe(
       client.executeSubscription(createQuery(args.query, args.variables)),
       subscribe(({ data, error }) => {
         setState(s => ({
@@ -45,12 +40,14 @@ export const useSubscription = <T = any, R = T>(
           error,
         }));
       })
-    )[0];
+    );
+
+    unsubscribe = teardown;
   };
 
   useEffect(() => {
     executeSubscription();
-    return () => executeUnsubscribe();
+    return unsubscribe;
   }, [args.query, args.variables]);
 
   return [state];
diff --git a/src/types.ts b/src/types.ts
index ebb84611..5cea4fc0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,6 +7,13 @@ export { ExecutionResult } from 'graphql';
 /** The type of GraphQL operation being executed. */
 export type OperationType = 'subscription' | 'query' | 'mutation' | 'teardown';
 
+/** The strategy that is used to request results from network and/or the cache. */
+export type RequestPolicy =
+  | 'cache-first'
+  | 'cache-only'
+  | 'network-only'
+  | 'cache-and-network';
+
 /** A Graphql query, mutation, or subscription. */
 export interface GraphQLRequest {
   query: string;
@@ -21,6 +28,7 @@ export type GraphqlSubscription = GraphQLRequest;
 export interface OperationContext {
   [key: string]: any;
   fetchOptions?: RequestInit;
+  requestPolicy: RequestPolicy;
   url: string;
 }
 
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 17b2883b..da47c21c 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,3 +2,7 @@ export { CombinedError } from './error';
 export { hashString } from './hash';
 export { createQuery, createMutation, createSubscription } from './query';
 export { formatTypeNames, gankTypeNamesFromResponse } from './typenames';
+
+export const noop = () => {
+  /* noop */
+};


Test patch:
diff --git a/src/components/__snapshots__/Query.test.tsx.snap b/src/components/__snapshots__/Query.test.tsx.snap
index 02c4b51d..3dd8d2a7 100644
--- a/src/components/__snapshots__/Query.test.tsx.snap
+++ b/src/components/__snapshots__/Query.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`on init default values match snapshot 1`] = `
 Object {
   "data": 1234,
   "error": undefined,
+  "executeQuery": [Function],
   "fetching": false,
 }
 `;
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index b7159e80..1abc56cc 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -18,6 +18,7 @@ Object {
       "fetchOptions": Object {
         "method": "POST",
       },
+      "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
     "key": "2",
@@ -49,6 +50,7 @@ Object {
       "fetchOptions": Object {
         "method": "POST",
       },
+      "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
     "key": "2",
diff --git a/src/exchanges/__snapshots__/subscription.test.ts.snap b/src/exchanges/__snapshots__/subscription.test.ts.snap
index 00015f0b..07ef5556 100644
--- a/src/exchanges/__snapshots__/subscription.test.ts.snap
+++ b/src/exchanges/__snapshots__/subscription.test.ts.snap
@@ -9,6 +9,7 @@ Object {
       "fetchOptions": Object {
         "method": "POST",
       },
+      "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
     "key": "{\\"query\\":\\"subscription subscribeToUser($user: String){\\\\n    user(user: $user) {\\\\n      name\\\\n    }\\\\n  }\\\\n  \\",\\"variables\\":{\\"user\\":\\"colin\\"}}",
diff --git a/src/hooks/useQuery.test.tsx b/src/hooks/useQuery.test.tsx
index fad0c86b..39265b14 100644
--- a/src/hooks/useQuery.test.tsx
+++ b/src/hooks/useQuery.test.tsx
@@ -68,7 +68,9 @@ describe('on initial useEffect', () => {
 
   it('passes query and vars to executeQuery', () => {
     renderer.create(<QueryUser {...props} />);
-    expect(client.executeQuery).toBeCalledWith(props);
+    expect(client.executeQuery).toBeCalledWith(props, {
+      requestPolicy: undefined,
+    });
   });
 });
 
diff --git a/src/test-utils/samples.ts b/src/test-utils/samples.ts
index 3af863aa..50577248 100644
--- a/src/test-utils/samples.ts
+++ b/src/test-utils/samples.ts
@@ -4,13 +4,15 @@ import {
   GraphqlQuery,
   GraphqlSubscription,
   Operation,
+  OperationContext,
   OperationResult,
 } from '../types';
 
-const context = {
+const context: OperationContext = {
   fetchOptions: {
     method: 'POST',
   },
+  requestPolicy: 'cache-first',
   url: 'http://localhost:3000/graphql',
 };