Repository: FormidableLabs/urql
Pull request #1159 (2020-11-16 18:57:17)

(vue) - Fix Vue Suspense by making thenable reactive
##  Summary

This should fix the thenable in `useQuery`'s Vue Suspense implementation by making it more reactive.

cc @LinusBorg 


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 4cf83ba7f76fc469c700fd68eb156bd624389cc4

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name      | Type  |
| --------- | ----- |
| @urql/vue | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/FormidableLabs/urql/new/fix/lazy-suspense-await?filename=.changeset/slow-socks-own.md&value=---%0A%22%40urql%2Fvue%22%3A%20patch%0A---%0A%0A(vue)%20-%20Fix%20Vue%20Suspense%20by%20making%20thenable%20reactive%0A)


kitten: Awesome! Generally I made the watchers more verbose as I found it's easier to make it work this way first and I'm sure we can trim it back down later ✌️

Good shout on the promise behaviour. I'm not sure if we do want to support a case where the source is a moving target as that could maybe lead to some gnarly looping behaviour when user code goes wrong, but generally I agree.

We can try to do that, yea

Patch:
diff --git a/.changeset/tasty-ravens-hide.md b/.changeset/tasty-ravens-hide.md
new file mode 100644
index 00000000..c7798c0d
--- /dev/null
+++ b/.changeset/tasty-ravens-hide.md
@@ -0,0 +1,5 @@
+---
+'@urql/vue': patch
+---
+
+Fix implementation of Vue's Suspense feature by making the lazy `PromiseLike` on the returned state passive.
diff --git a/packages/vue-urql/src/useQuery.ts b/packages/vue-urql/src/useQuery.ts
index 69041c78..5997a7cb 100644
--- a/packages/vue-urql/src/useQuery.ts
+++ b/packages/vue-urql/src/useQuery.ts
@@ -1,6 +1,20 @@
-import { Ref, ref, watchEffect, reactive, isRef } from 'vue';
+import { Ref, ref, watch, reactive, isRef } from 'vue';
 import { DocumentNode } from 'graphql';
-import { pipe, take, publish, share, onPush, toPromise, onEnd } from 'wonka';
+
+import {
+  Source,
+  concat,
+  fromValue,
+  map,
+  pipe,
+  take,
+  publish,
+  share,
+  onStart,
+  onPush,
+  toPromise,
+  onEnd,
+} from 'wonka';
 
 import {
   OperationResult,
@@ -36,14 +50,17 @@ export interface UseQueryState<T = any, V = object> {
   isPaused: Ref<boolean>;
   resume(): void;
   pause(): void;
-  executeQuery(
-    opts?: Partial<OperationContext>
-  ): PromiseLike<OperationResult<T, V>>;
+
+  executeQuery(opts?: Partial<OperationContext>): UseQueryResponse<T, V>;
 }
 
-export type UseQueryResponse<T = any, V = object> = UseQueryState<T, V> &
+export type UseQueryResponse<T, V> = UseQueryState<T, V> &
   PromiseLike<UseQueryState<T, V>>;
 
+const noop = () => {
+  /* noop */
+};
+
 export function useQuery<T = any, V = object>(
   _args: UseQueryArgs<T, V>
 ): UseQueryResponse<T, V> {
@@ -57,10 +74,6 @@ export function useQuery<T = any, V = object>(
   const operation: Ref<Operation<T, V> | undefined> = ref();
   const extensions: Ref<Record<string, any> | undefined> = ref();
 
-  const unsubscribe: Ref<() => void> = ref(() => {
-    /* noop */
-  });
-
   const isPaused: Ref<boolean> = isRef(_args.pause)
     ? _args.pause
     : ref(!!_args.pause);
@@ -69,7 +82,11 @@ export function useQuery<T = any, V = object>(
     createRequest<T, V>(args.query, args.variables as V) as any
   );
 
-  watchEffect(
+  const source: Ref<Source<OperationResult<T, V>> | undefined> = ref();
+  const unsubscribe: Ref<() => void> = ref(noop);
+
+  watch(
+    [args.query, args.variables],
     () => {
       const newRequest = createRequest<T, V>(args.query, args.variables as any);
       if (request.value.key !== newRequest.key) {
@@ -77,72 +94,94 @@ export function useQuery<T = any, V = object>(
       }
     },
     {
+      immediate: true,
       flush: 'sync',
     }
   );
 
-  let isThenable = false;
-
-  const executeQuery = (opts?: Partial<OperationContext>) => {
-    fetching.value = true;
-
-    const query$ = pipe(
-      client.executeQuery<T, V>(request.value, {
-        requestPolicy: args.requestPolicy,
-        pollInterval: args.pollInterval,
-        ...args.context,
-        ...opts,
-      }),
-      onEnd(() => {
-        fetching.value = false;
-      }),
-      onPush(res => {
-        isThenable = false;
-        data.value = res.data;
-        stale.value = !!res.stale;
-        fetching.value = false;
-        error.value = res.error;
-        operation.value = res.operation;
-        extensions.value = res.extensions;
-      }),
-      share
-    );
-
-    if (!isThenable) {
-      unsubscribe.value();
-      unsubscribe.value = publish(query$).unsubscribe;
+  watch(
+    [isPaused, request, args.requestPolicy, args.pollInterval, args.context],
+    () => {
+      if (!isPaused.value) {
+        source.value = pipe(
+          client.executeQuery<T, V>(request.value, {
+            requestPolicy: args.requestPolicy,
+            pollInterval: args.pollInterval,
+            ...args.context,
+          }),
+          share
+        );
+      } else {
+        source.value = undefined;
+      }
+    },
+    {
+      immediate: true,
+      flush: 'sync',
     }
+  );
 
-    return {
-      then(onFulfilled, onRejected) {
-        return pipe(query$, take(1), toPromise).then(onFulfilled, onRejected);
-      },
-    } as PromiseLike<OperationResult<T, V>>;
-  };
-
-  watchEffect(
-    onInvalidate => {
-      if (!isPaused.value) {
-        executeQuery();
-        onInvalidate(() => {
-          unsubscribe.value();
-        });
+  watch(
+    [source],
+    (_, __, onInvalidate) => {
+      if (!source.value) {
+        return unsubscribe.value();
       }
+
+      let cached: OperationResult<T, V> | undefined;
+
+      unsubscribe.value = pipe(
+        cached ? concat([fromValue(cached), source.value]) : source.value,
+        onStart(() => {
+          fetching.value = true;
+        }),
+        onEnd(() => {
+          fetching.value = false;
+        }),
+        onPush(res => {
+          cached = res;
+          data.value = res.data;
+          stale.value = !!res.stale;
+          fetching.value = false;
+          error.value = res.error;
+          operation.value = res.operation;
+          extensions.value = res.extensions;
+        }),
+        publish
+      ).unsubscribe;
+
+      onInvalidate(() => {
+        cached = undefined;
+        unsubscribe.value();
+      });
     },
     {
+      immediate: true,
       flush: 'pre',
     }
   );
 
-  const result = {
+  const state: UseQueryState<T, V> = {
     data,
     stale,
     error,
     operation,
     extensions,
     fetching,
-    executeQuery,
     isPaused,
+    executeQuery(opts?: Partial<OperationContext>): UseQueryResponse<T, V> {
+      source.value = pipe(
+        client.executeQuery<T, V>(request.value, {
+          requestPolicy: args.requestPolicy,
+          pollInterval: args.pollInterval,
+          ...args.context,
+          ...opts,
+        }),
+        share
+      );
+
+      return response;
+    },
     pause() {
       isPaused.value = true;
     },
@@ -151,13 +190,24 @@ export function useQuery<T = any, V = object>(
     },
   };
 
-  return {
-    ...result,
+  const response: UseQueryResponse<T, V> = {
+    ...state,
     then(onFulfilled, onRejected) {
-      isThenable = true;
-      return executeQuery()
-        .then(() => result)
-        .then(onFulfilled, onRejected);
+      let result$: Promise<UseQueryState<T, V>>;
+      if (fetching.value && source.value) {
+        result$ = pipe(
+          source.value,
+          take(1),
+          map(() => state),
+          toPromise
+        );
+      } else {
+        result$ = Promise.resolve(state);
+      }
+
+      return result$.then(onFulfilled, onRejected);
     },
   };
+
+  return response;
 }


Test patch:
diff --git a/packages/vue-urql/src/useQuery.test.ts b/packages/vue-urql/src/useQuery.test.ts
index f7bdaeac..e789ad56 100644
--- a/packages/vue-urql/src/useQuery.test.ts
+++ b/packages/vue-urql/src/useQuery.test.ts
@@ -7,7 +7,8 @@ jest.mock('vue', () => {
     inject: () => client,
   };
 });
-import { makeSubject } from 'wonka';
+
+import { pipe, makeSubject, fromValue, delay } from 'wonka';
 import { createClient } from '@urql/core';
 import { useQuery } from './useQuery';
 import { nextTick, reactive } from 'vue';
@@ -75,19 +76,17 @@ describe('useQuery', () => {
    *   I can't say why it's 3 in the latter case-
    *
    */
-  it.skip('runs Query and awaits results', async () => {
-    const subject = makeSubject<any>();
+  it('runs Query and awaits results', async () => {
     const executeQuery = jest
       .spyOn(client, 'executeQuery')
       .mockImplementation(() => {
-        subject.next({ data: { test: true } });
-        return subject.source;
+        return pipe(fromValue({ data: { test: true } }), delay(1)) as any;
       });
 
     const query = await useQuery({
       query: `{ test }`,
-      // pause: true,
     });
+
     expect(executeQuery).toHaveBeenCalledTimes(1);
     expect(query.fetching.value).toBe(false);
     expect(query.data.value).toEqual({ test: true });