Repository: FormidableLabs/urql
Pull request #506 (2020-01-13 18:24:51)

Fix synchronous suspense cases on client-side
This is a combined set of quirks that needed to be fixed. This PR also simplified the `toSuspenseSource` implementation as a shorter one is possible.

Essentially `wonka` had a problem with forwarding values in a `switchMap` operator, when the value was sent immediately on subscription before the operator had time to start.

The `react-wonka` implementation on the other hand would send a teardown even after the component suspended, which isn't necessary.

Lastly, all combined, a teardown operation would be sent at the wrong time and when React unsuspended the subtree it would create two new subscriptions which would cause the latest subscription to always miss the newest result.

See: https://github.com/kitten/react-wonka/pull/10
See: https://github.com/kitten/wonka/pull/69


Patch:
diff --git a/package.json b/package.json
index 95cdab2b..ea2f08a1 100644
--- a/package.json
+++ b/package.json
@@ -136,8 +136,8 @@
     "react": ">= 16.8.0"
   },
   "dependencies": {
-    "react-wonka": "^2.0.0",
+    "react-wonka": "^2.0.1",
     "scheduler": ">= 0.16.0",
-    "wonka": "^4.0.5"
+    "wonka": "^4.0.7"
   }
 }
diff --git a/src/utils/toSuspenseSource.ts b/src/utils/toSuspenseSource.ts
index a332fcc8..b7c6265a 100644
--- a/src/utils/toSuspenseSource.ts
+++ b/src/utils/toSuspenseSource.ts
@@ -1,48 +1,20 @@
-import { pipe, make, onPush, onEnd, subscribe, Source } from 'wonka';
+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> => {
-  // Create a new Source from scratch so we have full control over the Source's lifecycle
-  return make<T>(({ next, complete }) => {
-    let isCancelled = false;
-    let resolveSuspense: (value: T) => void;
-    let synchronousResult: undefined | T;
+export const toSuspenseSource = <T>(source: Source<T>): Source<T> => sink => {
+  const shared = share(source);
+  let hasResult = false;
+  let hasSuspended = false;
 
-    const { unsubscribe } = pipe(
-      source,
-      // The onPush and onEnd forward the underlying results as usual, so that when no
-      // suspense promise is thrown, the source behaves as it normally would
-      onPush(next),
-      onEnd(complete as any),
-      subscribe(value => {
-        // When this operation resolved synchronously assign the result to
-        // synchronousResult which will be picked up below
-        if (resolveSuspense === undefined) {
-          synchronousResult = value;
-        } else if (!isCancelled) {
-          // Otherwise resolve the thrown promise,
-          resolveSuspense(value);
-          // And end and teardown both sources, since suspense will abort the
-          // underlying rendering component anyway
-          complete();
-          unsubscribe();
-        }
-      })
-    );
+  pipe(
+    shared,
+    takeWhile(() => !hasSuspended),
+    onPush(() => (hasResult = true))
+  )(sink);
 
-    // If we have a synchronous result, push it onto this source, which is synchronous
-    // otherwise throw a new promise which will resolve later
-    if (synchronousResult === undefined) {
-      throw new Promise(resolve => {
-        resolveSuspense = resolve;
-      });
-    }
-
-    // Since promises aren't cancellable we have a flag that prevents
-    // the thrown promise from resolving if this source is cancelled
-    return () => {
-      isCancelled = true;
-      unsubscribe();
-    };
-  });
+  if (!hasResult) {
+    hasSuspended = true;
+    sink(0); /* End */
+    throw pipe(shared, take(1), toPromise);
+  }
 };
diff --git a/yarn.lock b/yarn.lock
index 1ac1bde9..ebfca61f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4718,10 +4718,10 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.12.0:
     react-is "^16.8.6"
     scheduler "^0.18.0"
 
-react-wonka@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/react-wonka/-/react-wonka-2.0.0.tgz#d62d87c9c93ec3e603ecf1582df3615aadc5c2e9"
-  integrity sha512-7q0CNBnSltRyzb61joCxKqVntHbRJRhP/WPxEx+zM8l9Yd+0IRevJuPG8iCamgrGphusX5xtEtd4yyX7qvRM1g==
+react-wonka@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/react-wonka/-/react-wonka-2.0.1.tgz#75bdf03dbad8ceb8c1066216f635f05ce2b642a5"
+  integrity sha512-mM2UH2gnK5LLzaqVWd6JCLrB1vO3I4PN/sQZbjvzsjms4vSv+nKwelNUftM0KeC+LtTPC4GGsuxyu2XJnsCUTw==
 
 react@^16.12.0:
   version "16.12.0"
@@ -5981,10 +5981,10 @@ wide-align@^1.1.0:
   dependencies:
     string-width "^1.0.2 || 2"
 
-wonka@^4.0.5:
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.5.tgz#3384b90ed8c1e6e182d6e2fb18468c33ab94e0af"
-  integrity sha512-XKnzSpsk2UcPfyjecdc14b7LZSPeOEhYEs+/oAZ+gXV9BuYIcZC3hpapFi2DFHj1Bk38/npusgkiSD0+KdyCzQ==
+wonka@^4.0.7:
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.7.tgz#b4934685bd2449367bd72ce7770bfe3e6cc8a68b"
+  integrity sha512-Uhyl2cgWCUksYtU0Jt8MSzKUqK4BVUrewWxnn1YlKL3Zco4sDcCUDkbgH0i762HJs1rtsq03cfzsCWxJKaDgVg==
 
 word-wrap@~1.2.3:
   version "1.2.3"


Test patch:
diff --git a/src/utils/toSuspenseSource.test.ts b/src/utils/toSuspenseSource.test.ts
index 56c7e9ed..b1c52ea9 100644
--- a/src/utils/toSuspenseSource.test.ts
+++ b/src/utils/toSuspenseSource.test.ts
@@ -72,7 +72,9 @@ it('throws a promise that resolves when the source emits a value', () => {
   expect(promise).toBeInstanceOf(Promise);
 
   next('test');
-  expect(result).toBe('test');
+
+  // The result came in asynchronously and the original source has ended
+  expect(result).toBe(undefined);
 
   return promise.then(resolved => {
     expect(resolved).toBe('test');