Repository: FormidableLabs/urql (ts)

Issue:
Previously canceled queries are not refetched, but fetching is true
You can try it here https://codesandbox.io/s/tender-hodgkin-5xiqh by changing the input before the query finishes (for example 1 -> 2 -> 3 -> 2)

Version 1.1.0

Pull request description:
Fix dedup ignoring torn down query operations
Fix #280 

When a query was torn down, the dedup exchange wasn't
removing its key from the inFlight keys.

This was causing us to ignore torn down queries forever.

Patch:
diff --git a/src/exchanges/dedup.ts b/src/exchanges/dedup.ts
index 6141eabb..cfaedc09 100644
--- a/src/exchanges/dedup.ts
+++ b/src/exchanges/dedup.ts
@@ -1,32 +1,36 @@
 import { filter, pipe, tap } from 'wonka';
-import { Exchange } from '../types';
+import { Exchange, Operation, OperationResult } from '../types';
 
 /** A default exchange for debouncing GraphQL requests. */
 export const dedupExchange: Exchange = ({ forward }) => {
-  const inFlight = new Set<number>();
+  const inFlightKeys = new Set<number>();
 
-  return ops$ =>
-    pipe(
-      forward(
-        pipe(
-          ops$,
-          filter(({ operationName, key }) => {
-            if (operationName !== 'query') {
-              return true;
-            }
+  const filterIncomingOperation = (operation: Operation) => {
+    const { key, operationName } = operation;
+    if (operationName === 'teardown') {
+      inFlightKeys.delete(key);
+      return true;
+    } else if (operationName !== 'query') {
+      return true;
+    }
 
-            const hasInFlightOp = inFlight.has(key);
+    const isInFlight = inFlightKeys.has(key);
+    inFlightKeys.add(key);
+    return !isInFlight;
+  };
 
-            if (!hasInFlightOp) {
-              inFlight.add(key);
-            }
+  const afterOperationResult = ({ operation }: OperationResult) => {
+    inFlightKeys.delete(operation.key);
+  };
 
-            return !hasInFlightOp;
-          })
-        )
-      ),
-      tap(res => {
-        inFlight.delete(res.operation.key);
-      })
+  return ops$ => {
+    const forward$ = pipe(
+      ops$,
+      filter(filterIncomingOperation)
     );
+    return pipe(
+      forward(forward$),
+      tap(afterOperationResult)
+    );
+  };
 };
diff --git a/src/hooks/useImmediateEffect.ts b/src/hooks/useImmediateEffect.ts
index df764167..18672e72 100644
--- a/src/hooks/useImmediateEffect.ts
+++ b/src/hooks/useImmediateEffect.ts
@@ -1,7 +1,6 @@
 /* eslint-disable react-hooks/exhaustive-deps */
 
-import { useRef, useEffect } from 'react';
-import { noop } from '../utils';
+import { useRef, useEffect, EffectCallback } from 'react';
 
 enum LifecycleState {
   WillMount = 0,
@@ -9,14 +8,12 @@ enum LifecycleState {
   Update = 2,
 }
 
-type Effect = () => () => void;
-
 /** This is a drop-in replacement for useEffect that will execute the first effect that happens during initial mount synchronously */
 export const useImmediateEffect = (
-  effect: Effect,
+  effect: EffectCallback,
   changes: ReadonlyArray<any>
 ) => {
-  const teardown = useRef(noop);
+  const teardown = useRef<ReturnType<EffectCallback>>(undefined);
   const state = useRef(LifecycleState.WillMount);
 
   // On initial render we just execute the effect
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index 8424b8f4..46233cc3 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -48,15 +48,7 @@ export const useQuery = <T = any, V = object>(
     (opts?: Partial<OperationContext>) => {
       unsubscribe.current();
 
-      // If useQuery is currently paused, set fetching to
-      // false and abort; otherwise start the query
-      if (args.pause) {
-        setState(s => ({ ...s, fetching: false }));
-        unsubscribe.current = noop;
-        return;
-      } else {
-        setState(s => ({ ...s, fetching: true }));
-      }
+      setState(s => ({ ...s, fetching: true }));
 
       [unsubscribe.current] = pipe(
         client.executeQuery(request, {
@@ -68,16 +60,18 @@ export const useQuery = <T = any, V = object>(
         })
       );
     },
-    [args.pause, args.requestPolicy, client, request, setState]
+    [args.requestPolicy, client, request, setState]
   );
 
-  // This calls executeQuery immediately during the initial mount and
-  // otherwise behaves like a normal useEffect; We call executeQuery
-  // everytime it, i.e. its input like request, changes
   useImmediateEffect(() => {
+    if (args.pause) {
+      unsubscribe.current();
+      return setState(s => ({ ...s, fetching: false }));
+    }
+
     executeQuery();
     return () => unsubscribe.current();
-  }, [executeQuery]);
+  }, [executeQuery, args.pause, setState]);
 
   return [state, executeQuery];
 };


Test patch:
diff --git a/src/exchanges/dedup.test.ts b/src/exchanges/dedup.test.ts
index 2f807bcc..cbc03caf 100644
--- a/src/exchanges/dedup.test.ts
+++ b/src/exchanges/dedup.test.ts
@@ -75,6 +75,19 @@ it('forwards duplicate query operations as usual after they respond', async () =
   expect(forwardedOperations.length).toBe(2);
 });
 
+it('forwards duplicate query operations after one was torn down', async () => {
+  shouldRespond = false; // We filter out our mock responses
+  const [ops$, next, complete] = input;
+  const exchange = dedupExchange(exchangeArgs)(ops$);
+
+  publish(exchange);
+  next(queryOperation);
+  next({ ...queryOperation, operationName: 'teardown' });
+  next(queryOperation);
+  complete();
+  expect(forwardedOperations.length).toBe(3);
+});
+
 it('always forwards mutation operations without deduplicating them', async () => {
   shouldRespond = false; // We filter out our mock responses
   const [ops$, next, complete] = input;