Repository: FormidableLabs/urql
Pull request #449 (2019-10-11 16:25:41)

Allow stale to be returned by exchanges
This adds a new `stale?: boolean` flag to `OperationResult` that exchanges can use to indicate whether they're still fetching more data in the background (like with `cache-and-network` or graphcache's partial results)

Hooks will normalise this flag to `stale: boolean` and pass it through to their results.

This supersedes: https://github.com/FormidableLabs/urql/pull/444


Patch:
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 407bfd52..24babe97 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -57,16 +57,19 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
       filter(op => !shouldSkip(op) && isOperationCached(op)),
       map(operation => {
         const cachedResult = resultCache.get(operation.key);
-        if (operation.context.requestPolicy === 'cache-and-network') {
-          reexecuteOperation(client, operation);
-        }
-
-        return {
+        const result: OperationResult = {
           ...cachedResult,
           operation: addMetadata(operation, {
             cacheOutcome: cachedResult ? 'hit' : 'miss',
           }),
         };
+
+        if (operation.context.requestPolicy === 'cache-and-network') {
+          result.stale = true;
+          reexecuteOperation(client, operation);
+        }
+
+        return result;
       })
     );
 
diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts
index 52f2077c..ebbfb439 100644
--- a/src/hooks/useMutation.ts
+++ b/src/hooks/useMutation.ts
@@ -7,6 +7,7 @@ import { CombinedError, createRequest } from '../utils';
 
 export interface UseMutationState<T> {
   fetching: boolean;
+  stale: boolean;
   data?: T;
   error?: CombinedError;
   extensions?: Record<string, any>;
@@ -27,6 +28,7 @@ export const useMutation = <T = any, V = object>(
 
   const [state, setState] = useState<UseMutationState<T>>({
     fetching: false,
+    stale: false,
     error: undefined,
     data: undefined,
     extensions: undefined,
@@ -36,6 +38,7 @@ export const useMutation = <T = any, V = object>(
     (variables?: V, context?: Partial<OperationContext>) => {
       setState({
         fetching: true,
+        stale: false,
         error: undefined,
         data: undefined,
         extensions: undefined,
@@ -47,8 +50,8 @@ export const useMutation = <T = any, V = object>(
         client.executeMutation(request, context || {}),
         toPromise
       ).then(result => {
-        const { data, error, extensions } = result;
-        setState({ fetching: false, data, error, extensions });
+        const { stale, data, error, extensions } = result;
+        setState({ fetching: false, stale: !!stale, data, error, extensions });
         return result;
       });
     },
diff --git a/src/hooks/useQuery.spec.ts b/src/hooks/useQuery.spec.ts
index 00f3442e..f7d3b9d5 100644
--- a/src/hooks/useQuery.spec.ts
+++ b/src/hooks/useQuery.spec.ts
@@ -61,6 +61,8 @@ describe('useQuery', () => {
     const [state] = result.current;
     expect(state).toEqual({
       fetching: true,
+      stale: false,
+      extensions: undefined,
       error: undefined,
       data: undefined,
     });
@@ -126,6 +128,8 @@ describe('useQuery', () => {
     const [state] = result.current;
     expect(state).toEqual({
       fetching: false,
+      stale: false,
+      extensions: undefined,
       error: 1,
       data: 0,
     });
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index f6858ede..cfb541cc 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -10,6 +10,7 @@ import { useRequest } from './useRequest';
 
 const initialState: UseQueryState<any> = {
   fetching: false,
+  stale: false,
   data: undefined,
   error: undefined,
   extensions: undefined,
@@ -26,6 +27,7 @@ export interface UseQueryArgs<V> {
 
 export interface UseQueryState<T> {
   fetching: boolean;
+  stale: boolean;
   data?: T;
   error?: CombinedError;
   extensions?: Record<string, any>;
@@ -70,8 +72,9 @@ export const useQuery = <T = any, V = object>(
             fromValue({ fetching: true }),
             pipe(
               query$,
-              map(({ data, error, extensions }) => ({
+              map(({ stale, data, error, extensions }) => ({
                 fetching: false,
+                stale: !!stale,
                 data,
                 error,
                 extensions,
@@ -82,7 +85,10 @@ export const useQuery = <T = any, V = object>(
           ]);
         }),
         // The individual partial results are merged into each previous result
-        scan((result, partial) => ({ ...result, ...partial }), initialState)
+        scan(
+          (result, partial) => ({ ...result, stale: false, ...partial }),
+          initialState
+        )
       ),
     useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]),
     initialState
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index f37b2e52..7918fd2a 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -10,6 +10,7 @@ import { OperationContext } from '../types';
 
 const initialState: UseSubscriptionState<any> = {
   fetching: false,
+  stale: false,
   data: undefined,
   error: undefined,
   extensions: undefined,
@@ -26,6 +27,7 @@ export type SubscriptionHandler<T, R> = (prev: R | undefined, data: T) => R;
 
 export interface UseSubscriptionState<T> {
   fetching: boolean;
+  stale: boolean;
   data?: T;
   error?: CombinedError;
   extensions?: Record<string, any>;
@@ -71,8 +73,9 @@ export const useSubscription = <T = any, R = T, V = object>(
             fromValue({ fetching: true }),
             pipe(
               subscription$,
-              map(({ data, error, extensions }) => ({
+              map(({ stale, data, error, extensions }) => ({
                 fetching: true,
+                stale: !!stale,
                 data,
                 error,
                 extensions,
@@ -86,15 +89,13 @@ export const useSubscription = <T = any, R = T, V = object>(
         scan((result, partial: any) => {
           const { current: handler } = handlerRef;
           // If a handler has been passed, it's used to merge new data in
-          if (partial.data !== undefined && typeof handler === 'function') {
-            return {
-              ...result,
-              ...partial,
-              data: handler(result.data, partial.data),
-            };
-          } else {
-            return { ...result, ...partial };
-          }
+          const data =
+            partial.data !== undefined
+              ? typeof handler === 'function'
+                ? handler(result.data, partial.data)
+                : partial.data
+              : result.data;
+          return { ...result, stale: false, ...partial, data };
         }, initialState)
       ),
     useMemo(() => (args.pause ? null : makeSubscription$()), [
diff --git a/src/types.ts b/src/types.ts
index 49b88b18..9c046cea 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -68,6 +68,8 @@ export interface OperationResult<Data = any> {
   error?: CombinedError;
   /** Optional extensions return by the Graphql server. */
   extensions?: Record<string, any>;
+  /** Optional stale flag added by exchanges that return stale results. */
+  stale?: boolean;
 }
 
 /** Input parameters for to an Exchange factory function. */


Test patch:
diff --git a/src/exchanges/cache.test.ts b/src/exchanges/cache.test.ts
index de0c0b25..6b202592 100755
--- a/src/exchanges/cache.test.ts
+++ b/src/exchanges/cache.test.ts
@@ -5,6 +5,7 @@ import {
   publish,
   Source,
   Subject,
+  forEach,
   scan,
   toPromise,
 } from 'wonka';
@@ -77,9 +78,13 @@ describe('on query', () => {
 
   it('respects cache-and-network', () => {
     const [ops$, next, complete] = input;
+    const result = jest.fn();
     const exchange = cacheExchange(exchangeArgs)(ops$);
 
-    publish(exchange);
+    pipe(
+      exchange,
+      forEach(result)
+    );
     next(queryOperation);
 
     next({
@@ -93,6 +98,8 @@ describe('on query', () => {
     complete();
     expect(forwardedOperations.length).toBe(1);
     expect(reexecuteOperation).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(2);
+    expect(result.mock.calls[1][0].stale).toBe(true);
 
     expect(reexecuteOperation.mock.calls[0][0]).toEqual({
       ...queryOperation,
diff --git a/src/hooks/__snapshots__/useMutation.test.tsx.snap b/src/hooks/__snapshots__/useMutation.test.tsx.snap
index bc1b8d00..e51218fe 100644
--- a/src/hooks/__snapshots__/useMutation.test.tsx.snap
+++ b/src/hooks/__snapshots__/useMutation.test.tsx.snap
@@ -6,5 +6,6 @@ Object {
   "error": undefined,
   "extensions": undefined,
   "fetching": false,
+  "stale": false,
 }
 `;
diff --git a/src/hooks/__snapshots__/useQuery.test.tsx.snap b/src/hooks/__snapshots__/useQuery.test.tsx.snap
index b4db0876..8e42fcf5 100644
--- a/src/hooks/__snapshots__/useQuery.test.tsx.snap
+++ b/src/hooks/__snapshots__/useQuery.test.tsx.snap
@@ -6,5 +6,6 @@ Object {
   "error": undefined,
   "extensions": undefined,
   "fetching": true,
+  "stale": false,
 }
 `;
diff --git a/src/hooks/__snapshots__/useSubscription.test.tsx.snap b/src/hooks/__snapshots__/useSubscription.test.tsx.snap
index 3202ca54..bfca60e1 100644
--- a/src/hooks/__snapshots__/useSubscription.test.tsx.snap
+++ b/src/hooks/__snapshots__/useSubscription.test.tsx.snap
@@ -6,5 +6,6 @@ Object {
   "error": 5678,
   "extensions": undefined,
   "fetching": true,
+  "stale": false,
 }
 `;
diff --git a/src/hooks/useSubscription.test.tsx b/src/hooks/useSubscription.test.tsx
index c3ca9077..80da947a 100644
--- a/src/hooks/useSubscription.test.tsx
+++ b/src/hooks/useSubscription.test.tsx
@@ -80,7 +80,12 @@ describe('on subscription', () => {
      * result of the state change.
      */
     wrapper.update(<SubscriptionUser q={query} />);
-    expect(state).toEqual({ ...data, fetching: true });
+    expect(state).toEqual({
+      ...data,
+      extensions: undefined,
+      fetching: true,
+      stale: false,
+    });
   });
 });