Repository: FormidableLabs/urql
Pull request #324 (2019-06-27 10:59:13)

Flag responses with cache hit status
When using the default cache exchange, responses will have an additional property `cacheHit` added to their context.

This property will not be added to the global context type definition as it is specific to this cache. Future cache implementations may use this `cacheHit` property in a different way (i.e. cache hits could be flagged as partial).


Patch:
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/hooks/useDevtoolsContext.ts b/src/hooks/useDevtoolsContext.ts
index c7b57c2c..dc10d668 100644
--- a/src/hooks/useDevtoolsContext.ts
+++ b/src/hooks/useDevtoolsContext.ts
@@ -18,7 +18,7 @@ const getHookParent = () => {
 const useDevtoolsContextHook = () => {
   const source = useRef(getHookParent());
 
-  return useMemo(() => [{ devtools: { source: source.current } }], []);
+  return useMemo(() => [{ meta: { source: source.current } }], []);
 };
 
 /** Creates additional context values for serving metadata to devtools. */
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/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,
+    },
+  },
+});


Test patch:
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/hooks/useMutation.test.tsx b/src/hooks/useMutation.test.tsx
index 5cffa0db..321adf5a 100644
--- a/src/hooks/useMutation.test.tsx
+++ b/src/hooks/useMutation.test.tsx
@@ -112,7 +112,7 @@ describe('on execute', () => {
     act(() => {
       execute(vars);
     });
-    expect(client.executeMutation.mock.calls[0][1]).toHaveProperty('devtools', {
+    expect(client.executeMutation.mock.calls[0][1]).toHaveProperty('meta', {
       source: 'MutationUser',
     });
   });
diff --git a/src/hooks/useQuery.test.tsx b/src/hooks/useQuery.test.tsx
index eb5cc4d3..9fa87b27 100644
--- a/src/hooks/useQuery.test.tsx
+++ b/src/hooks/useQuery.test.tsx
@@ -95,7 +95,7 @@ describe('on initial useEffect', () => {
     expect(client.executeQuery).toBeCalledWith(
       expect.any(Object),
       expect.objectContaining({
-        devtools: {
+        meta: {
           source: 'QueryUser',
         },
       })
diff --git a/src/hooks/useSubscription.test.tsx b/src/hooks/useSubscription.test.tsx
index 3ab36f77..ca8efbf9 100644
--- a/src/hooks/useSubscription.test.tsx
+++ b/src/hooks/useSubscription.test.tsx
@@ -65,7 +65,7 @@ describe('on initial useEffect', () => {
     renderer.create(<SubscriptionUser q={query} />);
     expect(client.executeSubscription).toBeCalledWith(
       expect.any(Object),
-      expect.objectContaining({ devtools: { source: 'SubscriptionUser' } })
+      expect.objectContaining({ meta: { source: 'SubscriptionUser' } })
     );
   });
 });