Repository: FormidableLabs/urql (ts)

Issue:
(suspense) - Suspense Exchange doesn't hold on to results and errors
Originally reported by @StevenLangbroek 

## Summary

**Steps to reproduce**

1. Create a client in suspense mode
2. Add a query that always errors
3. (optional) add `suspenseExchange`

See: https://github.com/StevenLangbroek/urql-suspense-loop-repro/tree/c8c88f42687e4f2c9ccc7839fa1526e13540769d

**Expected behavior**

The component suspends, the fallback renders, then on resolution of the query it displays the error.

**Actual behavior**

The component suspends, rerenders, then suspends again. This repeats indefinitely

## Root Cause

The `@urql/exchange-suspense` package has not been kept up-to-date and we should consider moving suspense support back into mainline stable, and not experimental. @StevenLangbroek: Let me know if anything else is still causing trouble that we can address with this issue too, e.g. unexpected results after the latest changes on `main`.

The exchange should hold on to a result for one tick until the component re-renders at which point it can then reuse the result. I suspect that the double-subscription approach to initialise the effect subscription erases the result stored by the `suspenseExchange` before the component fully mounts, thus triggering suspense again.


Pull request description:
(core) - Improve Suspense implementation and fix client-side Suspense
Resolve #1115 

## Summary

This reimplementation of our React Suspense support should improve edge-cases, support the `context.suspense === false` switch consistently, and fix the issue described in #1115 where uncached results would cause an infinite update loop when client-side Suspense was used.

The suspense logic has effectively moved to `useQuery` itself and caches result sources temporarily. In its implementation it assumes that all requests eventually are remounted, which is a safe assumption to make since after React Suspense triggers there's no way for a user to abort its eventual remount. Even if the cache leaks a suspense source, this source doesn't stay active forever and the added memory overhead would in theory be minimal.

## Set of changes

- Make `operation.context.suspense` more consistent. It now explicitly determines whether an operation has Suspense-mode enabled
- Move all `useSource` trigger effect to `useQuery` and `useSubscription` effectively (which also fixes the case of `useSubscription` triggering suspense unnecessarily in some cases
- Remove `toSuspenseSource` logic from `Client` and move it to `useQuery` with an added hook-wide cache
- Move `pollInterval` implementation to `executeRequestOperation` in case we find a way to reduce `useQuery`'s bundlesize impact now


Patch:
diff --git a/.changeset/strange-beans-sort.md b/.changeset/strange-beans-sort.md
new file mode 100644
index 00000000..c605c9bf
--- /dev/null
+++ b/.changeset/strange-beans-sort.md
@@ -0,0 +1,7 @@
+---
+'@urql/core': minor
+'@urql/preact': minor
+'urql': minor
+---
+
+Improve the Suspense implementation, which fixes edge-cases when Suspense is used with subscriptions, partially disabled, or _used on the client-side_. It has now been ensured that client-side suspense functions without the deprecated `suspenseExchange` and uncached results are loaded consistently. As part of this work, the `Client` itself does now never throw Suspense promises anymore, which is functionality that either way has no place outside of the React/Preact bindings.
diff --git a/exchanges/suspense/README.md b/exchanges/suspense/README.md
index 95a35e45..f0a59933 100644
--- a/exchanges/suspense/README.md
+++ b/exchanges/suspense/README.md
@@ -13,13 +13,11 @@ suspense on the server.
 But since `<Suspense>` is mainly intended for client-side use it made sense to build and publish
 this exchange, which allows you to try out `urql` and suspense in your React app!
 
-> ⚠️ **\*Deprecated**:
-> This package is deprecated! Usage of client-side suspense with `urql` isn't recommended anymore
-> and this packages has been marked as _deprecated_ after being _experimental_, since all it allows
-> for is to use Suspense as a fancier loading boundary, which isn't its intended use.
-> This exchange may still be useful when used with care, but it's worth keeping in mind that the
-> suspense patterns in `urql` for the client-side may change.
-> Suspense-mode usage for SSR remains unchanged and undeprecated however.
+## ⚠️ Deprecated
+
+Starting from `urql@1.11.0` / `@urql/preact@1.4.0` this exchange isn't required anymore to enable
+client-side suspense support. Instead the `useQuery` hook internally handles React Suspense and
+caches a result between Suspense and the subsequent re-mount of your component automatically.
 
 ## Quick Start Guide
 
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 42fe9df2..e66000a7 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -41,7 +41,6 @@ import {
 
 import {
   createRequest,
-  toSuspenseSource,
   withPromise,
   maskTypename,
   noop,
@@ -173,14 +172,19 @@ export class Client {
 
   createOperationContext = (
     opts?: Partial<OperationContext>
-  ): OperationContext => ({
-    url: this.url,
-    fetchOptions: this.fetchOptions,
-    fetch: this.fetch,
-    preferGetMethod: this.preferGetMethod,
-    ...opts,
-    requestPolicy: (opts || {}).requestPolicy || this.requestPolicy,
-  });
+  ): OperationContext => {
+    if (!opts) opts = {};
+
+    return {
+      url: this.url,
+      fetchOptions: this.fetchOptions,
+      fetch: this.fetch,
+      preferGetMethod: this.preferGetMethod,
+      ...opts,
+      suspense: opts.suspense || (opts.suspense !== false && this.suspense),
+      requestPolicy: opts.requestPolicy || this.requestPolicy,
+    };
+  };
 
   createRequestOperation = <Data = any, Variables = object>(
     kind: OperationType,
@@ -222,10 +226,9 @@ export class Client {
   executeRequestOperation<Data = any, Variables = object>(
     operation: Operation<Data, Variables>
   ): Source<OperationResult<Data, Variables>> {
-    const { key, kind } = operation;
     let operationResults$ = pipe(
       this.results$,
-      filter((res: OperationResult) => res.operation.key === key)
+      filter((res: OperationResult) => res.operation.key === operation.key)
     ) as Source<OperationResult<Data, Variables>>;
 
     if (this.maskTypename) {
@@ -238,7 +241,7 @@ export class Client {
       );
     }
 
-    if (kind === 'mutation') {
+    if (operation.kind === 'mutation') {
       // A mutation is always limited to just a single result and is never shared
       return pipe(
         operationResults$,
@@ -249,7 +252,9 @@ export class Client {
 
     const teardown$ = pipe(
       this.operations$,
-      filter((op: Operation) => op.kind === 'teardown' && op.key === key)
+      filter(
+        (op: Operation) => op.kind === 'teardown' && op.key === operation.key
+      )
     );
 
     const result$ = pipe(
@@ -263,11 +268,14 @@ export class Client {
       })
     );
 
-    return operation.context.suspense !== false &&
-      this.suspense &&
-      kind === 'query'
-      ? toSuspenseSource<OperationResult>(result$ as Source<OperationResult>)
-      : (result$ as Source<OperationResult>);
+    if (operation.kind === 'query' && operation.context.pollInterval) {
+      return pipe(
+        merge([fromValue(0), interval(operation.context.pollInterval)]),
+        switchMap(() => result$)
+      );
+    }
+
+    return result$;
   }
 
   query<Data = any, Variables extends object = {}>(
@@ -309,17 +317,7 @@ export class Client {
     opts?: Partial<OperationContext>
   ): Source<OperationResult<Data, Variables>> => {
     const operation = this.createRequestOperation('query', query, opts);
-    const response$ = this.executeRequestOperation<Data, Variables>(operation);
-    const { pollInterval } = operation.context;
-
-    if (pollInterval) {
-      return pipe(
-        merge([fromValue(0), interval(pollInterval)]),
-        switchMap(() => response$)
-      );
-    }
-
-    return response$;
+    return this.executeRequestOperation<Data, Variables>(operation);
   };
 
   subscription<Data = any, Variables extends object = {}>(
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index 3ab0237f..ee2437c4 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -2,7 +2,6 @@ export * from './error';
 export * from './request';
 export * from './result';
 export * from './typenames';
-export * from './toSuspenseSource';
 export * from './stringifyVariables';
 export * from './maskTypename';
 export * from './withPromise';
diff --git a/packages/core/src/utils/toSuspenseSource.ts b/packages/core/src/utils/toSuspenseSource.ts
deleted file mode 100644
index b7c6265a..00000000
--- a/packages/core/src/utils/toSuspenseSource.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { pipe, share, onPush, toPromise, takeWhile, take, Source } from 'wonka';
-
-/** This converts a Source to a suspense Source; It will forward the first result synchronously or throw a promise that resolves when the result becomes available */
-export const toSuspenseSource = <T>(source: Source<T>): Source<T> => sink => {
-  const shared = share(source);
-  let hasResult = false;
-  let hasSuspended = false;
-
-  pipe(
-    shared,
-    takeWhile(() => !hasSuspended),
-    onPush(() => (hasResult = true))
-  )(sink);
-
-  if (!hasResult) {
-    hasSuspended = true;
-    sink(0); /* End */
-    throw pipe(shared, take(1), toPromise);
-  }
-};
diff --git a/packages/preact-urql/src/hooks/useQuery.ts b/packages/preact-urql/src/hooks/useQuery.ts
index fabc7e3c..538c2bb6 100644
--- a/packages/preact-urql/src/hooks/useQuery.ts
+++ b/packages/preact-urql/src/hooks/useQuery.ts
@@ -1,12 +1,25 @@
 import { DocumentNode } from 'graphql';
-import { useCallback, useMemo } from 'preact/hooks';
-import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka';
+import { useEffect, useCallback, useMemo } from 'preact/hooks';
 
 import {
+  Source,
+  pipe,
+  share,
+  takeWhile,
+  concat,
+  fromValue,
+  switchMap,
+  map,
+  scan,
+} from 'wonka';
+
+import {
+  Client,
   TypedDocumentNode,
   CombinedError,
   OperationContext,
   RequestPolicy,
+  OperationResult,
   Operation,
 } from '@urql/core';
 
@@ -38,11 +51,51 @@ export type UseQueryResponse<Data = any, Variables = object> = [
   (opts?: Partial<OperationContext>) => void
 ];
 
+/** Convert the Source to a React Suspense source on demand */
+function toSuspenseSource<T>(source: Source<T>): Source<T> {
+  const shared = share(source);
+  let cache: T | void;
+  let resolve: (value: T) => void;
+
+  return sink => {
+    let hasSuspended = false;
+
+    pipe(
+      shared,
+      takeWhile(result => {
+        // The first result that is received will resolve the suspense
+        // promise after waiting for a microtick
+        if (cache === undefined) Promise.resolve(result).then(resolve);
+        cache = result;
+        return !hasSuspended;
+      })
+    )(sink);
+
+    // If we haven't got a previous result then start suspending
+    // otherwise issue the last known result immediately
+    if (cache !== undefined) {
+      const signal = [cache] as [T] & { tag: 1 };
+      signal.tag = 1;
+      sink(signal);
+    } else {
+      hasSuspended = true;
+      sink(0 /* End */);
+      throw new Promise<T>(_resolve => {
+        resolve = _resolve;
+      });
+    }
+  };
+}
+
+const isSuspense = (client: Client, context?: Partial<OperationContext>) =>
+  client.suspense && (!context || context.suspense !== false);
+
+const sources = new Map<number, Source<OperationResult>>();
+
 export function useQuery<Data = any, Variables = object>(
   args: UseQueryArgs<Variables, Data>
 ): UseQueryResponse<Data, Variables> {
   const client = useClient();
-
   // This creates a request which will keep a stable reference
   // if request.key doesn't change
   const request = useRequest<Data, Variables>(args.query, args.variables);
@@ -50,55 +103,69 @@ export function useQuery<Data = any, Variables = object>(
   // Create a new query-source from client.executeQuery
   const makeQuery$ = useCallback(
     (opts?: Partial<OperationContext>) => {
-      return client.executeQuery<Data, Variables>(request, {
-        requestPolicy: args.requestPolicy,
-        pollInterval: args.pollInterval,
-        ...args.context,
-        ...opts,
-      });
+      // Determine whether suspense is enabled for the given operation
+      const suspense = isSuspense(client, args.context);
+      let source: Source<OperationResult> | void = suspense
+        ? sources.get(request.key)
+        : undefined;
+      if (!source) {
+        source = client.executeQuery(request, {
+          requestPolicy: args.requestPolicy,
+          pollInterval: args.pollInterval,
+          ...args.context,
+          ...opts,
+        });
+
+        // Create a suspense source and cache it for the given request
+        if (suspense)
+          sources.set(request.key, (source = toSuspenseSource(source)));
+      }
+
+      return source;
     },
     [client, request, args.requestPolicy, args.pollInterval, args.context]
   );
 
+  const query$ = useMemo(() => {
+    return args.pause ? null : makeQuery$();
+  }, [args.pause, makeQuery$]);
+
   const [state, update] = useSource(
-    useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]),
-    useCallback(
-      (query$$, prevState: UseQueryState<Data, Variables> | undefined) => {
-        return pipe(
-          query$$,
-          switchMap(query$ => {
-            if (!query$) return fromValue({ fetching: false, stale: false });
-
-            return concat([
-              // Initially set fetching to true
-              fromValue({ fetching: true, stale: false }),
-              pipe(
-                query$,
-                map(({ stale, data, error, extensions, operation }) => ({
-                  fetching: false,
-                  stale: !!stale,
-                  data,
-                  error,
-                  operation,
-                  extensions,
-                }))
-              ),
-              // When the source proactively closes, fetching is set to false
-              fromValue({ fetching: false, stale: false }),
-            ]);
+    query$,
+    useCallback((query$$, prevState?: UseQueryState<Data, Variables>) => {
+      return pipe(
+        query$$,
+        switchMap(query$ => {
+          if (!query$) return fromValue({ fetching: false, stale: false });
+
+          return concat([
+            // Initially set fetching to true
+            fromValue({ fetching: true, stale: false }),
+            pipe(
+              query$,
+              map(({ stale, data, error, extensions, operation }) => ({
+                fetching: false,
+                stale: !!stale,
+                data,
+                error,
+                operation,
+                extensions,
+              }))
+            ),
+            // When the source proactively closes, fetching is set to false
+            fromValue({ fetching: false, stale: false }),
+          ]);
+        }),
+        // The individual partial results are merged into each previous result
+        scan(
+          (result: UseQueryState<Data, Variables>, partial) => ({
+            ...result,
+            ...partial,
           }),
-          // The individual partial results are merged into each previous result
-          scan(
-            (result: UseQueryState<Data, Variables>, partial) => ({
-              ...result,
-              ...partial,
-            }),
-            prevState || initialState
-          )
-        );
-      },
-      []
-    )
+          prevState || initialState
+        )
+      );
+    }, [])
   );
 
   // This is the imperative execute function passed to the user
@@ -107,5 +174,16 @@ export function useQuery<Data = any, Variables = object>(
     [update, makeQuery$]
   );
 
+  useEffect(() => {
+    sources.delete(request.key); // Delete any cached suspense source
+    if (!isSuspense(client, args.context)) {
+      update(query$);
+    }
+  }, [update, client, query$, request, args.context]);
+
+  if (isSuspense(client, args.context)) {
+    update(query$);
+  }
+
   return [state, executeQuery];
 }
diff --git a/packages/preact-urql/src/hooks/useSource.ts b/packages/preact-urql/src/hooks/useSource.ts
index 1498f099..84dc5256 100644
--- a/packages/preact-urql/src/hooks/useSource.ts
+++ b/packages/preact-urql/src/hooks/useSource.ts
@@ -1,11 +1,9 @@
 /* eslint-disable react-hooks/exhaustive-deps */
 
-import { useMemo, useEffect, useState, useRef } from 'preact/hooks';
+import { useMemo, useEffect, useState } from 'preact/hooks';
 
 import { Source, fromValue, makeSubject, pipe, concat, subscribe } from 'wonka';
 
-import { useClient } from '../context';
-
 type Updater<T> = (input: T) => void;
 
 let currentInit = false;
@@ -19,18 +17,14 @@ const isShallowDifferent = (a: any, b: any) => {
 
 export function useSource<T, R>(
   input: T,
-  transform: (input$: Source<T>, initial: R | undefined) => Source<R>
+  transform: (input$: Source<T>, initial?: R) => Source<R>
 ): [R, Updater<T>] {
-  const client = useClient();
-  const prev = useRef<R>();
-
   const [input$, updateInput] = useMemo((): [Source<T>, (value: T) => void] => {
     const subject = makeSubject<T>();
     const source = concat([fromValue(input), subject.source]);
 
-    let prevInput = input;
-    const updateInput = (input: T) => {
-      if (input !== prevInput) subject.next((prevInput = input));
+    const updateInput = (nextInput: T) => {
+      if (nextInput !== input) subject.next((input = nextInput));
     };
 
     return [source, updateInput];
@@ -38,38 +32,29 @@ export function useSource<T, R>(
 
   const [state, setState] = useState<R>(() => {
     currentInit = true;
-
+    let state: R;
     pipe(
-      transform(fromValue(input), prev.current),
+      transform(fromValue(input)),
       subscribe(value => {
-        prev.current = value;
+        state = value;
       })
     ).unsubscribe();
-
     currentInit = false;
-    return prev.current!;
+    return state!;
   });
 
   useEffect(() => {
     return pipe(
-      transform(input$, prev.current),
+      transform(input$, state),
       subscribe(value => {
         if (!currentInit) {
           setState(prevValue => {
-            return (prev.current = isShallowDifferent(prevValue, value)
-              ? value
-              : prevValue);
+            return isShallowDifferent(prevValue, value) ? value : prevValue;
           });
         }
       })
     ).unsubscribe;
-  }, [input$]);
-
-  useEffect(() => {
-    if (!client.suspense) updateInput(input);
-  }, [updateInput, input]);
-
-  if (client.suspense) updateInput(input);
+  }, [input$ /* `state` is only an initialiser */]);
 
   return [state, updateInput];
 }
diff --git a/packages/preact-urql/src/hooks/useSubscription.ts b/packages/preact-urql/src/hooks/useSubscription.ts
index 405daead..d1b0253d 100644
--- a/packages/preact-urql/src/hooks/useSubscription.ts
+++ b/packages/preact-urql/src/hooks/useSubscription.ts
@@ -1,6 +1,7 @@
 import { DocumentNode } from 'graphql';
-import { useCallback, useRef, useMemo } from 'preact/hooks';
+import { useEffect, useCallback, useRef, useMemo } from 'preact/hooks';
 import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka';
+
 import {
   TypedDocumentNode,
   CombinedError,
@@ -62,16 +63,14 @@ export function useSubscription<Data = any, Result = Data, Variables = object>(
     [client, request, args.context]
   );
 
+  const subscription$ = useMemo(() => {
+    return args.pause ? null : makeSubscription$();
+  }, [args.pause, makeSubscription$]);
+
   const [state, update] = useSource(
-    useMemo(() => (args.pause ? null : makeSubscription$()), [
-      args.pause,
-      makeSubscription$,
-    ]),
+    subscription$,
     useCallback(
-      (
-        subscription$$,
-        prevState: UseSubscriptionState<Result, Variables> | undefined
-      ) => {
+      (subscription$$, prevState?: UseSubscriptionState<Result, Variables>) => {
         return pipe(
           subscription$$,
           switchMap(subscription$ => {
@@ -122,5 +121,9 @@ export function useSubscription<Data = any, Result = Data, Variables = object>(
     [update, makeSubscription$]
   );
 
+  useEffect(() => {
+    update(subscription$);
+  }, [update, subscription$]);
+
   return [state, executeSubscription];
 }
diff --git a/packages/react-urql/src/hooks/useQuery.ts b/packages/react-urql/src/hooks/useQuery.ts
index 1d1aedba..35804c22 100644
--- a/packages/react-urql/src/hooks/useQuery.ts
+++ b/packages/react-urql/src/hooks/useQuery.ts
@@ -1,12 +1,25 @@
 import { DocumentNode } from 'graphql';
-import { useCallback, useMemo } from 'react';
-import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka';
+import { useEffect, useCallback, useMemo } from 'react';
 
 import {
+  Source,
+  pipe,
+  share,
+  takeWhile,
+  concat,
+  fromValue,
+  switchMap,
+  map,
+  scan,
+} from 'wonka';
+
+import {
+  Client,
   TypedDocumentNode,
   CombinedError,
   OperationContext,
   RequestPolicy,
+  OperationResult,
   Operation,
 } from '@urql/core';
 
@@ -38,11 +51,51 @@ export type UseQueryResponse<Data = any, Variables = object> = [
   (opts?: Partial<OperationContext>) => void
 ];
 
+/** Convert the Source to a React Suspense source on demand */
+function toSuspenseSource<T>(source: Source<T>): Source<T> {
+  const shared = share(source);
+  let cache: T | void;
+  let resolve: (value: T) => void;
+
+  return sink => {
+    let hasSuspended = false;
+
+    pipe(
+      shared,
+      takeWhile(result => {
+        // The first result that is received will resolve the suspense
+        // promise after waiting for a microtick
+        if (cache === undefined) Promise.resolve(result).then(resolve);
+        cache = result;
+        return !hasSuspended;
+      })
+    )(sink);
+
+    // If we haven't got a previous result then start suspending
+    // otherwise issue the last known result immediately
+    if (cache !== undefined) {
+      const signal = [cache] as [T] & { tag: 1 };
+      signal.tag = 1;
+      sink(signal);
+    } else {
+      hasSuspended = true;
+      sink(0 /* End */);
+      throw new Promise<T>(_resolve => {
+        resolve = _resolve;
+      });
+    }
+  };
+}
+
+const isSuspense = (client: Client, context?: Partial<OperationContext>) =>
+  client.suspense && (!context || context.suspense !== false);
+
+const sources = new Map<number, Source<OperationResult>>();
+
 export function useQuery<Data = any, Variables = object>(
   args: UseQueryArgs<Variables, Data>
 ): UseQueryResponse<Data, Variables> {
   const client = useClient();
-
   // This creates a request which will keep a stable reference
   // if request.key doesn't change
   const request = useRequest<Data, Variables>(args.query, args.variables);
@@ -50,55 +103,69 @@ export function useQuery<Data = any, Variables = object>(
   // Create a new query-source from client.executeQuery
   const makeQuery$ = useCallback(
     (opts?: Partial<OperationContext>) => {
-      return client.executeQuery<Data, Variables>(request, {
-        requestPolicy: args.requestPolicy,
-        pollInterval: args.pollInterval,
-        ...args.context,
-        ...opts,
-      });
+      // Determine whether suspense is enabled for the given operation
+      const suspense = isSuspense(client, args.context);
+      let source: Source<OperationResult> | void = suspense
+        ? sources.get(request.key)
+        : undefined;
+      if (!source) {
+        source = client.executeQuery(request, {
+          requestPolicy: args.requestPolicy,
+          pollInterval: args.pollInterval,
+          ...args.context,
+          ...opts,
+        });
+
+        // Create a suspense source and cache it for the given request
+        if (suspense)
+          sources.set(request.key, (source = toSuspenseSource(source)));
+      }
+
+      return source;
     },
     [client, request, args.requestPolicy, args.pollInterval, args.context]
   );
 
+  const query$ = useMemo(() => {
+    return args.pause ? null : makeQuery$();
+  }, [args.pause, makeQuery$]);
+
   const [state, update] = useSource(
-    useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]),
-    useCallback(
-      (query$$, prevState: UseQueryState<Data, Variables> | undefined) => {
-        return pipe(
-          query$$,
-          switchMap(query$ => {
-            if (!query$) return fromValue({ fetching: false, stale: false });
-
-            return concat([
-              // Initially set fetching to true
-              fromValue({ fetching: true, stale: false }),
-              pipe(
-                query$,
-                map(({ stale, data, error, extensions, operation }) => ({
-                  fetching: false,
-                  stale: !!stale,
-                  data,
-                  error,
-                  operation,
-                  extensions,
-                }))
-              ),
-              // When the source proactively closes, fetching is set to false
-              fromValue({ fetching: false, stale: false }),
-            ]);
+    query$,
+    useCallback((query$$, prevState?: UseQueryState<Data, Variables>) => {
+      return pipe(
+        query$$,
+        switchMap(query$ => {
+          if (!query$) return fromValue({ fetching: false, stale: false });
+
+          return concat([
+            // Initially set fetching to true
+            fromValue({ fetching: true, stale: false }),
+            pipe(
+              query$,
+              map(({ stale, data, error, extensions, operation }) => ({
+                fetching: false,
+                stale: !!stale,
+                data,
+                error,
+                operation,
+                extensions,
+              }))
+            ),
+            // When the source proactively closes, fetching is set to false
+            fromValue({ fetching: false, stale: false }),
+          ]);
+        }),
+        // The individual partial results are merged into each previous result
+        scan(
+          (result: UseQueryState<Data, Variables>, partial) => ({
+            ...result,
+            ...partial,
           }),
-          // The individual partial results are merged into each previous result
-          scan(
-            (result: UseQueryState<Data, Variables>, partial) => ({
-              ...result,
-              ...partial,
-            }),
-            prevState || initialState
-          )
-        );
-      },
-      []
-    )
+          prevState || initialState
+        )
+      );
+    }, [])
   );
 
   // This is the imperative execute function passed to the user
@@ -107,5 +174,16 @@ export function useQuery<Data = any, Variables = object>(
     [update, makeQuery$]
   );
 
+  useEffect(() => {
+    sources.delete(request.key); // Delete any cached suspense source
+    if (!isSuspense(client, args.context)) {
+      update(query$);
+    }
+  }, [update, client, query$, request, args.context]);
+
+  if (isSuspense(client, args.context)) {
+    update(query$);
+  }
+
   return [state, executeQuery];
 }
diff --git a/packages/react-urql/src/hooks/useSource.ts b/packages/react-urql/src/hooks/useSource.ts
index 67693459..2ec592b2 100644
--- a/packages/react-urql/src/hooks/useSource.ts
+++ b/packages/react-urql/src/hooks/useSource.ts
@@ -1,11 +1,9 @@
 /* eslint-disable react-hooks/exhaustive-deps */
 
-import { useMemo, useEffect, useState, useRef } from 'react';
+import { useMemo, useEffect, useState } from 'react';
 
 import { Source, fromValue, makeSubject, pipe, concat, subscribe } from 'wonka';
 
-import { useClient } from '../context';
-
 type Updater<T> = (input: T) => void;
 
 let currentInit = false;
@@ -19,18 +17,14 @@ const isShallowDifferent = (a: any, b: any) => {
 
 export function useSource<T, R>(
   input: T,
-  transform: (input$: Source<T>, initial: R | undefined) => Source<R>
+  transform: (input$: Source<T>, initial?: R) => Source<R>
 ): [R, Updater<T>] {
-  const client = useClient();
-  const prev = useRef<R>();
-
   const [input$, updateInput] = useMemo((): [Source<T>, (value: T) => void] => {
     const subject = makeSubject<T>();
     const source = concat([fromValue(input), subject.source]);
 
-    let prevInput = input;
-    const updateInput = (input: T) => {
-      if (input !== prevInput) subject.next((prevInput = input));
+    const updateInput = (nextInput: T) => {
+      if (nextInput !== input) subject.next((input = nextInput));
     };
 
     return [source, updateInput];
@@ -38,38 +32,29 @@ export function useSource<T, R>(
 
   const [state, setState] = useState<R>(() => {
     currentInit = true;
-
+    let state: R;
     pipe(
-      transform(fromValue(input), prev.current),
+      transform(fromValue(input)),
       subscribe(value => {
-        prev.current = value;
+        state = value;
       })
     ).unsubscribe();
-
     currentInit = false;
-    return prev.current!;
+    return state!;
   });
 
   useEffect(() => {
     return pipe(
-      transform(input$, prev.current),
+      transform(input$, state),
       subscribe(value => {
         if (!currentInit) {
           setState(prevValue => {
-            return (prev.current = isShallowDifferent(prevValue, value)
-              ? value
-              : prevValue);
+            return isShallowDifferent(prevValue, value) ? value : prevValue;
           });
         }
       })
     ).unsubscribe;
-  }, [input$]);
-
-  useEffect(() => {
-    if (!client.suspense) updateInput(input);
-  }, [updateInput, input]);
-
-  if (client.suspense) updateInput(input);
+  }, [input$ /* `state` is only an initialiser */]);
 
   return [state, updateInput];
 }
diff --git a/packages/react-urql/src/hooks/useSubscription.ts b/packages/react-urql/src/hooks/useSubscription.ts
index bd93b040..3f1e2676 100644
--- a/packages/react-urql/src/hooks/useSubscription.ts
+++ b/packages/react-urql/src/hooks/useSubscription.ts
@@ -1,5 +1,5 @@
 import { DocumentNode } from 'graphql';
-import { useCallback, useRef, useMemo } from 'react';
+import { useEffect, useCallback, useRef, useMemo } from 'react';
 import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka';
 
 import {
@@ -63,16 +63,14 @@ export function useSubscription<Data = any, Result = Data, Variables = object>(
     [client, request, args.context]
   );
 
+  const subscription$ = useMemo(() => {
+    return args.pause ? null : makeSubscription$();
+  }, [args.pause, makeSubscription$]);
+
   const [state, update] = useSource(
-    useMemo(() => (args.pause ? null : makeSubscription$()), [
-      args.pause,
-      makeSubscription$,
-    ]),
+    subscription$,
     useCallback(
-      (
-        subscription$$,
-        prevState: UseSubscriptionState<Result, Variables> | undefined
-      ) => {
+      (subscription$$, prevState?: UseSubscriptionState<Result, Variables>) => {
         return pipe(
           subscription$$,
           switchMap(subscription$ => {
@@ -123,5 +121,9 @@ export function useSubscription<Data = any, Result = Data, Variables = object>(
     [update, makeSubscription$]
   );
 
+  useEffect(() => {
+    update(subscription$);
+  }, [update, subscription$]);
+
   return [state, executeSubscription];
 }


Test patch:
diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts
index 71128ef2..125c3b94 100755
--- a/packages/core/src/client.test.ts
+++ b/packages/core/src/client.test.ts
@@ -122,6 +122,7 @@ describe('promisified methods', () => {
       requestPolicy: 'cache-and-network',
       fetchOptions: undefined,
       fetch: undefined,
+      suspense: false,
       preferGetMethod: false,
     });
     expect(mutationResult).toHaveProperty('then');
diff --git a/packages/core/src/utils/toSuspenseSource.test.ts b/packages/core/src/utils/toSuspenseSource.test.ts
deleted file mode 100644
index b1c52ea9..00000000
--- a/packages/core/src/utils/toSuspenseSource.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import {
-  pipe,
-  onStart,
-  onPush,
-  onEnd,
-  fromValue,
-  fromArray,
-  makeSubject,
-  never,
-  publish,
-  subscribe,
-} from 'wonka';
-
-import { toSuspenseSource } from './toSuspenseSource';
-
-it('does nothing when not subscribed to', () => {
-  const start = jest.fn();
-
-  pipe(fromValue('test'), onStart(start), toSuspenseSource);
-
-  expect(start).not.toHaveBeenCalled();
-});
-
-it('resolves synchronously when the source resolves synchronously', () => {
-  const start = jest.fn();
-  const push = jest.fn();
-  let result;
-
-  pipe(
-    fromValue('test'),
-    onStart(start),
-    onPush(push),
-    toSuspenseSource,
-    subscribe(value => {
-      result = value;
-    })
-  );
-
-  expect(result).toBe('test');
-  expect(start).toHaveBeenCalledTimes(1);
-  expect(push).toHaveBeenCalledTimes(1);
-});
-
-it('throws a promise when the source is not resolving immediately', () => {
-  expect(() => {
-    pipe(never, toSuspenseSource as any, publish);
-  }).toThrow(expect.any(Promise));
-});
-
-it('throws a promise that resolves when the source emits a value', () => {
-  const { source, next } = makeSubject();
-  const end = jest.fn();
-
-  let promise;
-  let result;
-
-  try {
-    pipe(
-      source,
-      toSuspenseSource,
-      onEnd(end),
-      subscribe(value => {
-        expect(value).toBe('test');
-        result = value;
-      })
-    );
-  } catch (error) {
-    promise = error;
-  }
-
-  // Expect it to have thrown
-  expect(promise).toBeInstanceOf(Promise);
-
-  next('test');
-
-  // The result came in asynchronously and the original source has ended
-  expect(result).toBe(undefined);
-
-  return promise.then(resolved => {
-    expect(resolved).toBe('test');
-    expect(end).toHaveBeenCalled();
-  });
-});
-
-it('behaves like a normal source when the first result was synchronous', async () => {
-  const push = jest.fn();
-  await new Promise(resolve => {
-    pipe(fromArray([1, 2]), toSuspenseSource, onEnd(resolve), subscribe(push));
-  });
-
-  expect(push).toHaveBeenCalledTimes(2);
-});
-
-it('still supports cancellation', async () => {
-  let unsubscribe;
-  const end = jest.fn();
-
-  try {
-    ({ unsubscribe } = pipe(
-      fromArray([1, 2]),
-      toSuspenseSource,
-      onEnd(end),
-      publish
-    ));
-  } catch (promise) {
-    expect(promise).toBe(expect.any(Promise));
-  }
-
-  unsubscribe();
-  expect(end).toHaveBeenCalledTimes(1);
-});
diff --git a/packages/react-urql/src/test-utils/ssr.test.tsx b/packages/react-urql/src/test-utils/ssr.test.tsx
index 872ef64a..42746e76 100644
--- a/packages/react-urql/src/test-utils/ssr.test.tsx
+++ b/packages/react-urql/src/test-utils/ssr.test.tsx
@@ -25,6 +25,7 @@ const context: OperationContext = {
   },
   requestPolicy: 'cache-first',
   url: 'http://localhost:3000/graphql',
+  suspense: true,
 };
 
 export const queryGql: GraphQLRequest = {
@@ -97,23 +98,6 @@ describe('server-side rendering', () => {
     });
   });
 
-  it('correctly executes suspense and populates the SSR cache', async () => {
-    let promise;
-
-    try {
-      pipe(client.executeRequestOperation(queryOperation), publish);
-    } catch (error) {
-      promise = error;
-    }
-
-    expect(promise).toBeInstanceOf(Promise);
-    const result = await promise;
-    expect(result.data).not.toBe(undefined);
-
-    const data = ssr.extractData();
-    expect(Object.keys(data).length).toBe(1);
-  });
-
   it('works for an actual component tree', async () => {
     const Query = () => {
       useQuery({