Repository: FormidableLabs/urql
Pull request #1600 (2021-05-02 21:54:41)

(core) - Avoid dispatching an operation for already active cache-first/only operations
## Summary

Previously, in #1515 we merged the groundwork for making identical operations that are run concurrently more intuitive. Namely, when an active source is active multiple times then the sources that newly become active will have the last known result replayed. (There was one bug in there that's fixed in here; See "Set of changes")

We can use the same principle to make concurrent operations in general more intuitive. While it'd seem to make sense to create some kind of "result cache" that prevents unnecessary work, we now have the foundation that we need to make this intuitive and seamless, on top of existing logic.

With these changes applied we will now _not_ dispatch a new operation on the `Client` and instead use the replayed result when the request policy _isn't_ `network-only` or `cache-and-network` (since these intuitively instruct the cache to fetch). Instead when `cache-first` or `cache-only` are used and a replay result exists, the `Client` will only issue the replayed result and will not do anything else.

## Set of changes

- Fix an issue where a reused (but previously unsubscribed from) `replayOnStart` source wouldn't reset the `replay` value to `undefined`.
- Create a `'pre' | 'post'` mode for `replayOnStart` where `'pre'` is the existing behaviour and `'post'` issues the `start()` if no replay result is available
- Update tests to account for the new difference


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 1c64d952d5ba6402a559e49718880f7bcd677d38

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/prevent-unnecessary-work?filename=.changeset/quick-pandas-give.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0AFeat%2Fprevent%20unnecessary%20work%0A)



Patch:
diff --git a/.changeset/beige-candles-kneel.md b/.changeset/beige-candles-kneel.md
new file mode 100644
index 00000000..b742e278
--- /dev/null
+++ b/.changeset/beige-candles-kneel.md
@@ -0,0 +1,5 @@
+---
+'@urql/vue': minor
+---
+
+A `useClientHandle()` function has been added. This creates a `handle` on which all `use*` hooks can be called, like `await handle.useQuery(...)` or `await handle.useSubscription(...)` which is useful for sequentially chaining hook calls in an `async setup()` function or preserve the right instance of a `Client` across lifecycle hooks.
diff --git a/.changeset/fluffy-ligers-draw.md b/.changeset/fluffy-ligers-draw.md
new file mode 100644
index 00000000..7fc42b2f
--- /dev/null
+++ b/.changeset/fluffy-ligers-draw.md
@@ -0,0 +1,5 @@
+---
+'@urql/vue': patch
+---
+
+The `useClient()` function will now throw a more helpful error when it's called outside of any lifecycle hooks.
diff --git a/.changeset/large-frogs-tease.md b/.changeset/large-frogs-tease.md
new file mode 100644
index 00000000..f56a0696
--- /dev/null
+++ b/.changeset/large-frogs-tease.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+With the "single-source behavior" the `Client` will now also avoid executing an operation if it's already active, has a previous result available, and is either run with the `cache-first` or `cache-only` request policies. This is similar to a "short circuiting" behavior, where unnecessary work is avoided by not issuing more operations into the exchange pipeline than expected.
diff --git a/docs/api/vue.md b/docs/api/vue.md
index c4039f3d..25e0acb8 100644
--- a/docs/api/vue.md
+++ b/docs/api/vue.md
@@ -104,14 +104,28 @@ The result furthermore carries several utility methods:
 [Read more about how to use the `useSubscription` API on the "Subscriptions"
 page.](../advanced/subscriptions.md#vue)
 
+## useClientHandle
+
+The `useClientHandle()` function may, like the other `use*` functions, be called either in
+`setup()` or another lifecycle hook, and returns a so called "client handle". Using this `handle` we
+can access the [`Client`](./core.md#client) directly via the `client` property or call the other
+`use*` functions as methods, which will be directly bound to this `client`. This may be useful when
+chaining these methods inside an `async setup()` lifecycle function.
+
+| Method                 | Description                                                                                                               |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| `client`               | Contains the raw [`Client`](./core.md#client) reference, which allows the `Client` to be used directly.                   |
+| `useQuery(...)`        | Accepts the same arguments as the `useQuery` function, but will always use the `Client` from the handle's context.        |
+| `useMutation(...)`     | Accepts the same arguments as the `useMutation` function, but will always use the `Client` from the handle's context.     |
+| `useSubscription(...)` | Accepts the same arguments as the `useSubscription` function, but will always use the `Client` from the handle's context. |
+
 ## Context API
 
 In Vue the [`Client`](./core.md#client) is provided either to your app or to a parent component of a
 given subtree and is then subsequently injected whenever one of the above composition functions is
 used.
 
-You can manually retrieve the `Client` in your component by calling `useClient`. Symmetrically you
-can provide the `Client` from any of your components using the `provideClient` function.
+You can provide the `Client` from any of your components using the `provideClient` function.
 Alternatively, `@urql/vue` also has a default export of a [Vue Plugin function](https://v3.vuejs.org/guide/plugins.html#using-a-plugin).
 
 Both `provideClient` and the plugin function either accept an [instance of
diff --git a/docs/basics/vue.md b/docs/basics/vue.md
index dd4ae0e5..c58577ed 100644
--- a/docs/basics/vue.md
+++ b/docs/basics/vue.md
@@ -470,6 +470,52 @@ boundary, we'll get Vue Suspense to work correctly and trigger this loading stat
 suspends this component will switch to using its `#fallback` template rather than its `#default`
 template.
 
+### Chaining calls in Vue Suspense
+
+As shown [above](#vue-suspense), in Vue Suspense the `async setup()` lifecycle function can be used
+to set up queries in advance, wait for them to have fetched some data, and then let the component
+render as usual.
+
+However, because the `async setup()` function can be used with `await`-ed promise calls, we may run
+into situations where we're trying to call functions like `useQuery()` after we've already awaited
+another promise and will be outside of the synchronous scope of the `setup()` lifecycle. This means
+that the `useQuery` (and `useSubscription` & `useMutation`) functions won't have access to the
+`Client` anymore that we'd have set up using `provideClient`.
+
+To prevent this, we can create something called a "client handle" using the `useClientHandle`
+function.
+
+```js
+import { gql, useClientHandle } from '@urql/vue';
+
+export default {
+  async setup() {
+    const handle = useClientHandle();
+
+    await Promise.resolve(); // NOTE: This could be any await call
+
+    const result = await handle.useQuery({
+      query: gql`
+        {
+          todos {
+            id
+            title
+          }
+        }
+      `,
+    });
+
+    return { data: result.data };
+  },
+};
+```
+
+As we can see, when we use `handle.useQuery()` we're able to still create query results although we've
+interrupted the synchronous `setup()` lifecycle with a `Promise.resolve()` delay. This would also
+allow us to create chained queries by using
+[`computed`](https://v3.vuejs.org/guide/reactivity-computed-watchers.html#computed-values) to use an
+output from a preceding result in a next `handle.useQuery()` call.
+
 ### Reading on
 
 There are some more tricks we can use with `useQuery`. [Read more about its API in the API docs for
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 7c25a63b..74af03ab 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -273,9 +273,14 @@ export class Client {
         share
       );
     } else {
+      const mode =
+        operation.context.requestPolicy === 'cache-and-network' ||
+        operation.context.requestPolicy === 'network-only'
+          ? 'pre'
+          : 'post';
       active = pipe(
         result$,
-        replayOnStart(() => {
+        replayOnStart(mode, () => {
           this.activeOperations.set(operation.key, active!);
           this.dispatchOperation(operation);
         })
diff --git a/packages/core/src/utils/streamUtils.ts b/packages/core/src/utils/streamUtils.ts
index 1e18311e..148d0a4b 100644
--- a/packages/core/src/utils/streamUtils.ts
+++ b/packages/core/src/utils/streamUtils.ts
@@ -20,14 +20,20 @@ export function withPromise<T>(source$: Source<T>): PromisifiedSource<T> {
   return source$ as PromisifiedSource<T>;
 }
 
+export type ReplayMode = 'pre' | 'post';
+
 export function replayOnStart<T extends OperationResult>(
-  start?: () => void
+  mode: ReplayMode,
+  start: () => void
 ): Operator<T, T> {
   return source$ => {
     let replay: T | void;
 
     const shared$ = pipe(
       source$,
+      onEnd(() => {
+        replay = undefined;
+      }),
       onPush(value => {
         replay = value;
       }),
@@ -37,18 +43,24 @@ export function replayOnStart<T extends OperationResult>(
     return make<T>(observer => {
       const prevReplay = replay;
 
-      const subscription = pipe(
+      return pipe(
         shared$,
         onEnd(observer.complete),
         onStart(() => {
-          if (start) start();
-          if (prevReplay !== undefined && prevReplay === replay)
-            observer.next({ ...prevReplay, stale: true });
+          if (mode === 'pre') {
+            start();
+          }
+
+          if (prevReplay !== undefined && prevReplay === replay) {
+            observer.next(
+              mode === 'pre' ? { ...prevReplay, stale: true } : prevReplay
+            );
+          } else if (mode === 'post') {
+            start();
+          }
         }),
         subscribe(observer.next)
-      );
-
-      return subscription.unsubscribe;
+      ).unsubscribe;
     });
   };
 }
diff --git a/packages/vue-urql/example/package.json b/packages/vue-urql/example/package.json
index 91851e76..f6c6e06f 100644
--- a/packages/vue-urql/example/package.json
+++ b/packages/vue-urql/example/package.json
@@ -7,7 +7,7 @@
   },
   "dependencies": {
     "@urql/vue": "link:../",
-    "vue": "^3.0.2"
+    "vue": "^3.0.11"
   },
   "devDependencies": {
     "@vue/compiler-sfc": "^3.0.2",
diff --git a/packages/vue-urql/example/src/App.vue b/packages/vue-urql/example/src/App.vue
index 506b9942..af070770 100644
--- a/packages/vue-urql/example/src/App.vue
+++ b/packages/vue-urql/example/src/App.vue
@@ -18,7 +18,7 @@ export default {
   name: 'App',
   setup() {
     provideClient({
-      url: 'https://countries-274616.ew.r.appspot.com/',
+      url: 'https://trygql.dev/graphql/basic-pokedex',
     });
   },
   components: {
diff --git a/packages/vue-urql/example/src/components/HelloWorld.vue b/packages/vue-urql/example/src/components/HelloWorld.vue
index 4b4cff0a..ce58b920 100644
--- a/packages/vue-urql/example/src/components/HelloWorld.vue
+++ b/packages/vue-urql/example/src/components/HelloWorld.vue
@@ -1,26 +1,59 @@
 <template>
   <div>
-    <div v-if="data">
-      <pre>{{ JSON.stringify(data) }}</pre>
+    <div v-if="pokemons">
+      <pre>{{ JSON.stringify(pokemons) }}</pre>
     </div>
+    <div v-if="pokemon">
+      <pre>{{ JSON.stringify(pokemon) }}</pre>
+    </div>
+    <button @click="nextPokemon">Next Pokemon</button>
   </div>
 </template>
 
 <script>
-import { useQuery } from '@urql/vue';
+import { ref, computed } from 'vue';
+import { gql, useClientHandle } from '@urql/vue';
 
 export default {
   async setup() {
-    const query = `
-      query {
-        Country {
-          name
+    const handle = useClientHandle();
+
+    const pokemons = await handle.useQuery({
+      query: gql`
+        {
+          pokemons(limit: 10) {
+            id
+            name
+          }
+        }
+      `
+    });
+
+    const index = ref(0);
+
+    const pokemon = await handle.useQuery({
+      query: gql`
+        query ($id: ID!) {
+          pokemon(id: $id) {
+            id
+            name
+          }
         }
-      }
-    `;
+      `,
+      variables: {
+        id: computed(() => pokemons.data.value.pokemons[index.value].id),
+      },
+    });
 
-    const result = useQuery({ query });
-    return { data: result.data };
+    return {
+      pokemons: pokemons.data,
+      pokemon: pokemon.data,
+      nextPokemon() {
+        index.value = index.value < (pokemons.data.value.pokemons.length - 1)
+          ? index.value + 1
+          : 0;
+      },
+    };
   },
   name: 'HelloWorld',
 }
diff --git a/packages/vue-urql/example/yarn.lock b/packages/vue-urql/example/yarn.lock
index 8b790862..7949edec 100644
--- a/packages/vue-urql/example/yarn.lock
+++ b/packages/vue-urql/example/yarn.lock
@@ -37,6 +37,11 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@graphql-typed-document-node/core@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.0.tgz#0eee6373e11418bfe0b5638f654df7a4ca6a3950"
+  integrity sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==
+
 "@koa/cors@^3.1.0":
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
@@ -266,17 +271,29 @@
     "@types/express-serve-static-core" "*"
     "@types/mime" "*"
 
-"@urql/core@^1.13.1":
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/@urql/core/-/core-1.13.1.tgz#7247c27dccd7570010de91730d1f16fd15892829"
-  integrity sha512-Zl4UwvcE9JbWKzrtxnlmfF+rkX50GzK5dpMlB6FnUYF0sLmuGMxp67lnhTQsfTNJ+41bkj4lk0PMWEnG7KUsTw==
+"@urql/core@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@urql/core/-/core-2.0.0.tgz#b4936dd51fe84cb570506d9ee2579149689f7535"
+  integrity sha512-Qj24CG8ullqZZsYmjrSH0JhH+nY7kj8GbVbA9si3KUjlYs75A/MBQU3i97j6oWyGldDBapyis2CfaQeXKbv8rA==
   dependencies:
+    "@graphql-typed-document-node/core" "^3.1.0"
     wonka "^4.0.14"
 
 "@urql/vue@link:..":
   version "0.0.0"
   uid ""
 
+"@vue/compiler-core@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.0.11.tgz#5ef579e46d7b336b8735228758d1c2c505aae69a"
+  integrity sha512-6sFj6TBac1y2cWCvYCA8YzHJEbsVkX7zdRs/3yK/n1ilvRqcn983XvpBbnN3v4mZ1UiQycTvOiajJmOgN9EVgw==
+  dependencies:
+    "@babel/parser" "^7.12.0"
+    "@babel/types" "^7.12.0"
+    "@vue/shared" "3.0.11"
+    estree-walker "^2.0.1"
+    source-map "^0.6.1"
+
 "@vue/compiler-core@3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.0.2.tgz#7790b7a1fcbba5ace4d81a70ce59096fa5c95734"
@@ -288,6 +305,14 @@
     estree-walker "^2.0.1"
     source-map "^0.6.1"
 
+"@vue/compiler-dom@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.11.tgz#b15fc1c909371fd671746020ba55b5dab4a730ee"
+  integrity sha512-+3xB50uGeY5Fv9eMKVJs2WSRULfgwaTJsy23OIltKgMrynnIj8hTYY2UL97HCoz78aDw1VDXdrBQ4qepWjnQcw==
+  dependencies:
+    "@vue/compiler-core" "3.0.11"
+    "@vue/shared" "3.0.11"
+
 "@vue/compiler-dom@3.0.2", "@vue/compiler-dom@^3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.2.tgz#1d40de04bcdf9aabb79fb6a802dd70a2f3c2992a"
@@ -326,6 +351,13 @@
     "@vue/compiler-dom" "3.0.2"
     "@vue/shared" "3.0.2"
 
+"@vue/reactivity@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.0.11.tgz#07b588349fd05626b17f3500cbef7d4bdb4dbd0b"
+  integrity sha512-SKM3YKxtXHBPMf7yufXeBhCZ4XZDKP9/iXeQSC8bBO3ivBuzAi4aZi0bNoeE2IF2iGfP/AHEt1OU4ARj4ao/Xw==
+  dependencies:
+    "@vue/shared" "3.0.11"
+
 "@vue/reactivity@3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.0.2.tgz#42ed5af6025b494a5e69b05169fcddf04eebfe77"
@@ -333,6 +365,14 @@
   dependencies:
     "@vue/shared" "3.0.2"
 
+"@vue/runtime-core@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.0.11.tgz#c52dfc6acf3215493623552c1c2919080c562e44"
+  integrity sha512-87XPNwHfz9JkmOlayBeCCfMh9PT2NBnv795DSbi//C/RaAnc/bGZgECjmkD7oXJ526BZbgk9QZBPdFT8KMxkAg==
+  dependencies:
+    "@vue/reactivity" "3.0.11"
+    "@vue/shared" "3.0.11"
+
 "@vue/runtime-core@3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.0.2.tgz#d7ed462af1cb0bf9836668e4e6fab3f2f4b1bc00"
@@ -341,6 +381,15 @@
     "@vue/reactivity" "3.0.2"
     "@vue/shared" "3.0.2"
 
+"@vue/runtime-dom@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.0.11.tgz#7a552df21907942721feb6961c418e222a699337"
+  integrity sha512-jm3FVQESY3y2hKZ2wlkcmFDDyqaPyU3p1IdAX92zTNeCH7I8zZ37PtlE1b9NlCtzV53WjB4TZAYh9yDCMIEumA==
+  dependencies:
+    "@vue/runtime-core" "3.0.11"
+    "@vue/shared" "3.0.11"
+    csstype "^2.6.8"
+
 "@vue/runtime-dom@3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.0.2.tgz#9d166d03225558025d3d80f5039b646e0051b71c"
@@ -350,6 +399,11 @@
     "@vue/shared" "3.0.2"
     csstype "^2.6.8"
 
+"@vue/shared@3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.11.tgz#20d22dd0da7d358bb21c17f9bde8628152642c77"
+  integrity sha512-b+zB8A2so8eCE0JsxjL24J7vdGl8rzPQ09hZNhystm+KqSbKcAej1A+Hbva1rCMmTTqA+hFnUSDc5kouEo0JzA==
+
 "@vue/shared@3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.2.tgz#419bd85a2ebdbd4f42963e98c5a1b103452176d9"
@@ -2009,6 +2063,15 @@ vite@^1.0.0-rc.9:
     vue "^3.0.2"
     ws "^7.3.1"
 
+vue@^3.0.11:
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/vue/-/vue-3.0.11.tgz#c82f9594cbf4dcc869241d4c8dd3e08d9a8f4b5f"
+  integrity sha512-3/eUi4InQz8MPzruHYSTQPxtM3LdZ1/S/BvaU021zBnZi0laRUyH6pfuE4wtUeLvI8wmUNwj5wrZFvbHUXL9dw==
+  dependencies:
+    "@vue/compiler-dom" "3.0.11"
+    "@vue/runtime-dom" "3.0.11"
+    "@vue/shared" "3.0.11"
+
 vue@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.0.2.tgz#9d5b7b2983f35e64a34d13c7c9d6831239feca3c"
diff --git a/packages/vue-urql/package.json b/packages/vue-urql/package.json
index 5a820685..392e954d 100644
--- a/packages/vue-urql/package.json
+++ b/packages/vue-urql/package.json
@@ -52,7 +52,7 @@
   },
   "devDependencies": {
     "graphql": "^15.1.0",
-    "vue": "^3.0.2"
+    "vue": "^3.0.11"
   },
   "peerDependencies": {
     "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0",
diff --git a/packages/vue-urql/src/index.ts b/packages/vue-urql/src/index.ts
index a716ee89..4d0715fc 100644
--- a/packages/vue-urql/src/index.ts
+++ b/packages/vue-urql/src/index.ts
@@ -1,8 +1,30 @@
 export * from '@urql/core';
-export * from './useClient';
-export * from './useQuery';
-export * from './useMutation';
-export * from './useSubscription';
+
+export * from './useClientHandle';
+export { install, provideClient } from './useClient';
+
+export {
+  useQuery,
+  UseQueryArgs,
+  UseQueryResponse,
+  UseQueryState,
+} from './useQuery';
+
+export {
+  useSubscription,
+  UseSubscriptionArgs,
+  UseSubscriptionResponse,
+  UseSubscriptionState,
+  SubscriptionHandlerArg,
+  SubscriptionHandler,
+} from './useSubscription';
+
+export {
+  useMutation,
+  UseMutationResponse,
+  UseMutationState,
+} from './useMutation';
+
 import { install } from './useClient';
 
 export default install;
diff --git a/packages/vue-urql/src/useClient.ts b/packages/vue-urql/src/useClient.ts
index e088115f..d74a6878 100644
--- a/packages/vue-urql/src/useClient.ts
+++ b/packages/vue-urql/src/useClient.ts
@@ -1,4 +1,4 @@
-import { App, inject, provide } from 'vue';
+import { App, getCurrentInstance, inject, provide } from 'vue';
 import { Client, ClientOptions } from '@urql/core';
 
 export function provideClient(opts: ClientOptions | Client) {
@@ -13,6 +13,12 @@ export function install(app: App, opts: ClientOptions | Client) {
 }
 
 export function useClient(): Client {
+  if (process.env.NODE_ENV !== 'production' && !getCurrentInstance()) {
+    throw new Error(
+      'use* functions may only be called during the `setup()` or other lifecycle hooks.'
+    );
+  }
+
   const client = inject('$urql') as Client;
   if (process.env.NODE_ENV !== 'production' && !client) {
     throw new Error(
diff --git a/packages/vue-urql/src/useClientHandle.ts b/packages/vue-urql/src/useClientHandle.ts
new file mode 100644
index 00000000..42b1bb22
--- /dev/null
+++ b/packages/vue-urql/src/useClientHandle.ts
@@ -0,0 +1,104 @@
+import { DocumentNode } from 'graphql';
+import { Client, TypedDocumentNode } from '@urql/core';
+import {
+  WatchStopHandle,
+  getCurrentInstance,
+  onMounted,
+  onBeforeUnmount,
+} from 'vue';
+
+import { useClient } from './useClient';
+
+import { callUseQuery, UseQueryArgs, UseQueryResponse } from './useQuery';
+
+import { callUseMutation, UseMutationResponse } from './useMutation';
+
+import {
+  callUseSubscription,
+  UseSubscriptionArgs,
+  SubscriptionHandlerArg,
+  UseSubscriptionResponse,
+} from './useSubscription';
+
+export interface ClientHandle {
+  client: Client;
+
+  useQuery<T = any, V = object>(
+    args: UseQueryArgs<T, V>
+  ): UseQueryResponse<T, V>;
+
+  useSubscription<T = any, R = T, V = object>(
+    args: UseSubscriptionArgs<T, V>,
+    handler?: SubscriptionHandlerArg<T, R>
+  ): UseSubscriptionResponse<T, R, V>;
+
+  useMutation<T = any, V = any>(
+    query: TypedDocumentNode<T, V> | DocumentNode | string
+  ): UseMutationResponse<T, V>;
+}
+
+export function useClientHandle(): ClientHandle {
+  const client = useClient();
+  const stops: WatchStopHandle[] = [];
+
+  onBeforeUnmount(() => {
+    let stop: WatchStopHandle | void;
+    while ((stop = stops.shift())) stop();
+  });
+
+  const handle: ClientHandle = {
+    client,
+
+    useQuery<T = any, V = object>(
+      args: UseQueryArgs<T, V>
+    ): UseQueryResponse<T, V> {
+      return callUseQuery(args, client, stops);
+    },
+
+    useSubscription<T = any, R = T, V = object>(
+      args: UseSubscriptionArgs<T, V>,
+      handler?: SubscriptionHandlerArg<T, R>
+    ): UseSubscriptionResponse<T, R, V> {
+      return callUseSubscription(args, handler, client, stops);
+    },
+
+    useMutation<T = any, V = any>(
+      query: TypedDocumentNode<T, V> | DocumentNode | string
+    ): UseMutationResponse<T, V> {
+      return callUseMutation(query, client);
+    },
+  };
+
+  if (process.env.NODE_ENV !== 'production') {
+    onMounted(() => {
+      Object.assign(handle, {
+        useQuery<T = any, V = object>(
+          args: UseQueryArgs<T, V>
+        ): UseQueryResponse<T, V> {
+          if (process.env.NODE_ENV !== 'production' && !getCurrentInstance()) {
+            throw new Error(
+              '`handle.useQuery()` should only be called in the `setup()` or a lifecycle hook.'
+            );
+          }
+
+          return callUseQuery(args, client, stops);
+        },
+
+        useSubscription<T = any, R = T, V = object>(
+          args: UseSubscriptionArgs<T, V>,
+          handler?: SubscriptionHandlerArg<T, R>
+        ): UseSubscriptionResponse<T, R, V> {
+          if (process.env.NODE_ENV !== 'production' && !getCurrentInstance()) {
+            throw new Error(
+              '`handle.useSubscription()` should only be called in the `setup()` or a lifecycle hook.'
+            );
+          }
+
+          return callUseSubscription(args, handler, client, stops);
+        },
+      });
+    });
+  }
+
+  return handle;
+}
diff --git a/packages/vue-urql/src/useMutation.ts b/packages/vue-urql/src/useMutation.ts
index 783b885f..f9087fac 100644
--- a/packages/vue-urql/src/useMutation.ts
+++ b/packages/vue-urql/src/useMutation.ts
@@ -1,7 +1,10 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+
 import { ref, Ref } from 'vue';
 import { DocumentNode } from 'graphql';
 
 import {
+  Client,
   TypedDocumentNode,
   CombinedError,
   Operation,
@@ -29,8 +32,13 @@ export type UseMutationResponse<T, V> = UseMutationState<T, V>;
 export function useMutation<T = any, V = any>(
   query: TypedDocumentNode<T, V> | DocumentNode | string
 ): UseMutationResponse<T, V> {
-  const client = useClient();
+  return callUseMutation(query);
+}
 
+export function callUseMutation<T = any, V = any>(
+  query: TypedDocumentNode<T, V> | DocumentNode | string,
+  client: Client = useClient()
+): UseMutationResponse<T, V> {
   const data: Ref<T | undefined> = ref();
   const stale: Ref<boolean> = ref(false);
   const fetching: Ref<boolean> = ref(false);
diff --git a/packages/vue-urql/src/useQuery.ts b/packages/vue-urql/src/useQuery.ts
index 9ff343dd..cdf0305f 100644
--- a/packages/vue-urql/src/useQuery.ts
+++ b/packages/vue-urql/src/useQuery.ts
@@ -1,6 +1,9 @@
-import { Ref, ref, watchEffect, reactive, isRef } from 'vue';
+/* eslint-disable react-hooks/rules-of-hooks */
+
 import { DocumentNode } from 'graphql';
 
+import { WatchStopHandle, Ref, ref, watchEffect, reactive, isRef } from 'vue';
+
 import {
   Source,
   concat,
@@ -20,6 +23,7 @@ import {
 } from 'wonka';
 
 import {
+  Client,
   OperationResult,
   TypedDocumentNode,
   CombinedError,
@@ -87,10 +91,17 @@ function replayOne<T>(source: Source<T>): Source<T> {
 }
 
 export function useQuery<T = any, V = object>(
-  _args: UseQueryArgs<T, V>
+  args: UseQueryArgs<T, V>
+): UseQueryResponse<T, V> {
+  return callUseQuery(args);
+}
+
+export function callUseQuery<T = any, V = object>(
+  _args: UseQueryArgs<T, V>,
+  client: Client = useClient(),
+  stops: WatchStopHandle[] = []
 ): UseQueryResponse<T, V> {
   const args = reactive(_args);
-  const client = useClient();
 
   const data: Ref<T | undefined> = ref();
   const stale: Ref<boolean> = ref(false);
@@ -112,12 +123,14 @@ export function useQuery<T = any, V = object>(
     (query$: undefined | Source<OperationResult<T, V>>) => void
   > = ref(null as any);
 
-  watchEffect(() => {
-    const newRequest = createRequest<T, V>(args.query, args.variables as any);
-    if (request.value.key !== newRequest.key) {
-      request.value = newRequest;
-    }
-  }, watchOptions);
+  stops.push(
+    watchEffect(() => {
+      const newRequest = createRequest<T, V>(args.query, args.variables as any);
+      if (request.value.key !== newRequest.key) {
+        request.value = newRequest;
+      }
+    }, watchOptions)
+  );
 
   const state: UseQueryState<T, V> = {
     data,
@@ -148,61 +161,65 @@ export function useQuery<T = any, V = object>(
 
   const getState = () => state;
 
-  watchEffect(
-    onInvalidate => {
-      const subject = makeSubject<Source<any>>();
-      source.value = pipe(subject.source, replayOne);
-      next.value = (value: undefined | Source<any>) => {
-        const query$ = pipe(
-          value
-            ? pipe(
-                value,
-                onStart(() => {
-                  fetching.value = true;
-                  stale.value = false;
-                }),
-                onPush(res => {
-                  data.value = res.data;
-                  stale.value = !!res.stale;
-                  fetching.value = false;
-                  error.value = res.error;
-                  operation.value = res.operation;
-                  extensions.value = res.extensions;
-                }),
-                share
-              )
-            : fromValue(undefined),
-          onEnd(() => {
-            fetching.value = false;
-            stale.value = false;
-          })
+  stops.push(
+    watchEffect(
+      onInvalidate => {
+        const subject = makeSubject<Source<any>>();
+        source.value = pipe(subject.source, replayOne);
+        next.value = (value: undefined | Source<any>) => {
+          const query$ = pipe(
+            value
+              ? pipe(
+                  value,
+                  onStart(() => {
+                    fetching.value = true;
+                    stale.value = false;
+                  }),
+                  onPush(res => {
+                    data.value = res.data;
+                    stale.value = !!res.stale;
+                    fetching.value = false;
+                    error.value = res.error;
+                    operation.value = res.operation;
+                    extensions.value = res.extensions;
+                  }),
+                  share
+                )
+              : fromValue(undefined),
+            onEnd(() => {
+              fetching.value = false;
+              stale.value = false;
+            })
+          );
+
+          subject.next(query$);
+        };
+
+        onInvalidate(
+          pipe(source.value, switchAll, map(getState), publish).unsubscribe
         );
+      },
+      {
+        // NOTE: This part of the query pipeline is only initialised once and will need
+        // to do so synchronously
+        flush: 'sync',
+      }
+    )
+  );
 
-        subject.next(query$);
-      };
-
-      onInvalidate(
-        pipe(source.value, switchAll, map(getState), publish).unsubscribe
+  stops.push(
+    watchEffect(() => {
+      next.value(
+        !isPaused.value
+          ? client.executeQuery<T, V>(request.value, {
+              requestPolicy: args.requestPolicy,
+              ...args.context,
+            })
+          : undefined
       );
-    },
-    {
-      // NOTE: This part of the query pipeline is only initialised once and will need
-      // to do so synchronously
-      flush: 'sync',
-    }
+    }, watchOptions)
   );
 
-  watchEffect(() => {
-    next.value(
-      !isPaused.value
-        ? client.executeQuery<T, V>(request.value, {
-            requestPolicy: args.requestPolicy,
-            ...args.context,
-          })
-        : undefined
-    );
-  }, watchOptions);
-
   const response: UseQueryResponse<T, V> = {
     ...state,
     then(onFulfilled, onRejected) {
diff --git a/packages/vue-urql/src/useSubscription.ts b/packages/vue-urql/src/useSubscription.ts
index 7ee072a8..77f3c215 100644
--- a/packages/vue-urql/src/useSubscription.ts
+++ b/packages/vue-urql/src/useSubscription.ts
@@ -1,8 +1,12 @@
-import { Ref, ref, watchEffect, reactive, isRef } from 'vue';
+/* eslint-disable react-hooks/rules-of-hooks */
+
 import { DocumentNode } from 'graphql';
 import { Source, pipe, publish, share, onStart, onPush, onEnd } from 'wonka';
 
+import { WatchStopHandle, Ref, ref, watchEffect, reactive, isRef } from 'vue';
+
 import {
+  Client,
   OperationResult,
   TypedDocumentNode,
   CombinedError,
@@ -24,6 +28,7 @@ export interface UseSubscriptionArgs<T = any, V = object> {
 }
 
 export type SubscriptionHandler<T, R> = (prev: R | undefined, data: T) => R;
+export type SubscriptionHandlerArg<T, R> = MaybeRef<SubscriptionHandler<T, R>>;
 
 export interface UseSubscriptionState<T = any, R = T, V = object> {
   fetching: Ref<boolean>;
@@ -49,11 +54,19 @@ const watchOptions = {
 };
 
 export function useSubscription<T = any, R = T, V = object>(
+  args: UseSubscriptionArgs<T, V>,
+  handler?: SubscriptionHandlerArg<T, R>
+): UseSubscriptionResponse<T, R, V> {
+  return callUseSubscription(args, handler);
+}
+
+export function callUseSubscription<T = any, R = T, V = object>(
   _args: UseSubscriptionArgs<T, V>,
-  handler?: MaybeRef<SubscriptionHandler<T, R>>
+  handler?: SubscriptionHandlerArg<T, R>,
+  client: Client = useClient(),
+  stops: WatchStopHandle[] = []
 ): UseSubscriptionResponse<T, R, V> {
   const args = reactive(_args);
-  const client = useClient();
 
   const data: Ref<R | undefined> = ref();
   const stale: Ref<boolean> = ref(false);
@@ -74,55 +87,61 @@ export function useSubscription<T = any, R = T, V = object>(
 
   const source: Ref<Source<OperationResult<T, V>> | undefined> = ref();
 
-  watchEffect(() => {
-    const newRequest = createRequest<T, V>(args.query, args.variables as any);
-    if (request.value.key !== newRequest.key) {
-      request.value = newRequest;
-    }
-  }, watchOptions);
+  stops.push(
+    watchEffect(() => {
+      const newRequest = createRequest<T, V>(args.query, args.variables as any);
+      if (request.value.key !== newRequest.key) {
+        request.value = newRequest;
+      }
+    }, watchOptions)
+  );
 
-  watchEffect(() => {
-    if (!isPaused.value) {
-      source.value = pipe(
-        client.executeSubscription<T, V>(request.value, {
-          ...args.context,
-        }),
-        share
-      );
-    } else {
-      source.value = undefined;
-    }
-  }, watchOptions);
-
-  watchEffect(onInvalidate => {
-    if (source.value) {
-      onInvalidate(
-        pipe(
-          source.value,
-          onStart(() => {
-            fetching.value = true;
-          }),
-          onEnd(() => {
-            fetching.value = false;
-          }),
-          onPush(result => {
-            fetching.value = true;
-            (data.value =
-              result.data !== undefined
-                ? typeof scanHandler.value === 'function'
-                  ? scanHandler.value(data.value as any, result.data!)
-                  : result.data
-                : (result.data as any)),
-              (error.value = result.error);
-            extensions.value = result.extensions;
-            stale.value = !!result.stale;
-            operation.value = result.operation;
+  stops.push(
+    watchEffect(() => {
+      if (!isPaused.value) {
+        source.value = pipe(
+          client.executeSubscription<T, V>(request.value, {
+            ...args.context,
           }),
-          publish
-        ).unsubscribe
-      );
-    }
-  }, watchOptions);
+          share
+        );
+      } else {
+        source.value = undefined;
+      }
+    }, watchOptions)
+  );
+
+  stops.push(
+    watchEffect(onInvalidate => {
+      if (source.value) {
+        onInvalidate(
+          pipe(
+            source.value,
+            onStart(() => {
+              fetching.value = true;
+            }),
+            onEnd(() => {
+              fetching.value = false;
+            }),
+            onPush(result => {
+              fetching.value = true;
+              (data.value =
+                result.data !== undefined
+                  ? typeof scanHandler.value === 'function'
+                    ? scanHandler.value(data.value as any, result.data!)
+                    : result.data
+                  : (result.data as any)),
+                (error.value = result.error);
+              extensions.value = result.extensions;
+              stale.value = !!result.stale;
+              operation.value = result.operation;
+            }),
+            publish
+          ).unsubscribe
+        );
+      }
+    }, watchOptions)
+  );
 
   const state: UseSubscriptionState<T, R, V> = {
     data,
diff --git a/yarn.lock b/yarn.lock
index cfc02ec4..770ac18e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15801,7 +15801,7 @@ vm-browserify@^1.0.1:
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
   integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
 
-vue@^3.0.2:
+vue@^3.0.11:
   version "3.0.11"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.0.11.tgz#c82f9594cbf4dcc869241d4c8dd3e08d9a8f4b5f"
   integrity sha512-3/eUi4InQz8MPzruHYSTQPxtM3LdZ1/S/BvaU021zBnZi0laRUyH6pfuE4wtUeLvI8wmUNwj5wrZFvbHUXL9dw==


Test patch:
diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts
index 7bf8f20a..09678edb 100755
--- a/packages/core/src/client.test.ts
+++ b/packages/core/src/client.test.ts
@@ -519,7 +519,7 @@ describe('shared sources behavior', () => {
     jest.useRealTimers();
   });
 
-  it('replays results from prior operation result as needed', async () => {
+  it('replays results from prior operation result as needed (cache-first)', async () => {
     const exchange: Exchange = () => ops$ => {
       let i = 0;
       return pipe(
@@ -556,10 +556,111 @@ describe('shared sources behavior', () => {
 
     expect(resultTwo).toHaveBeenCalledWith({
       data: 1,
+      operation: queryOperation,
+    });
+
+    jest.advanceTimersByTime(1);
+
+    // With cache-first we don't expect a new operation to be issued
+    expect(resultTwo).toHaveBeenCalledTimes(1);
+  });
+
+  it('replays results from prior operation result as needed (network-only)', async () => {
+    const exchange: Exchange = () => ops$ => {
+      let i = 0;
+      return pipe(
+        ops$,
+        map(op => ({
+          data: ++i,
+          operation: op,
+        })),
+        delay(1)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const operation = makeOperation('query', queryOperation, {
+      ...queryOperation.context,
+      requestPolicy: 'network-only',
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(operation), subscribe(resultOne));
+
+    expect(resultOne).toHaveBeenCalledTimes(0);
+
+    jest.advanceTimersByTime(1);
+
+    expect(resultOne).toHaveBeenCalledTimes(1);
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 1,
+      operation,
+    });
+
+    pipe(client.executeRequestOperation(operation), subscribe(resultTwo));
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 1,
+      operation,
       stale: true,
+    });
+
+    jest.advanceTimersByTime(1);
+
+    // With network-only we expect a new operation to be issued, hence a new result
+    expect(resultTwo).toHaveBeenCalledTimes(2);
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 2,
+      operation,
+    });
+  });
+
+  it('does not replay values from a past subscription', async () => {
+    const exchange: Exchange = () => ops$ => {
+      let i = 0;
+      return pipe(
+        ops$,
+        filter(op => op.kind !== 'teardown'),
+        map(op => ({
+          data: ++i,
+          operation: op,
+        })),
+        delay(1)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    // We keep the source in-memory
+    const source = client.executeRequestOperation(queryOperation);
+    const resultOne = jest.fn();
+    let subscription;
+
+    subscription = pipe(source, subscribe(resultOne));
+
+    expect(resultOne).toHaveBeenCalledTimes(0);
+    jest.advanceTimersByTime(1);
+
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 1,
       operation: queryOperation,
     });
 
+    subscription.unsubscribe();
+    const resultTwo = jest.fn();
+    subscription = pipe(source, subscribe(resultTwo));
+
+    expect(resultTwo).toHaveBeenCalledTimes(0);
     jest.advanceTimersByTime(1);
 
     expect(resultTwo).toHaveBeenCalledWith({
@@ -586,19 +687,21 @@ describe('shared sources behavior', () => {
       exchanges: [exchange],
     });
 
+    const operation = makeOperation('query', queryOperation, {
+      ...queryOperation.context,
+      requestPolicy: 'network-only',
+    });
+
     const resultOne = jest.fn();
     const resultTwo = jest.fn();
 
-    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
-
-    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+    pipe(client.executeRequestOperation(operation), subscribe(resultOne));
+    pipe(client.executeRequestOperation(operation), subscribe(resultTwo));
 
-    expect(resultOne).toHaveBeenCalledTimes(1);
     expect(resultTwo).toHaveBeenCalledTimes(1);
-
     expect(resultTwo).toHaveBeenCalledWith({
       data: 1,
-      operation: queryOperation,
+      operation,
       stale: true,
     });
   });
@@ -628,7 +731,7 @@ describe('shared sources behavior', () => {
     expect(resultTwo).toHaveBeenCalledTimes(0);
   });
 
-  it('skips replaying results when a result is emitted immediately', () => {
+  it('skips replaying results when a result is emitted immediately (network-only)', () => {
     const exchange: Exchange = () => ops$ => {
       let i = 0;
       return pipe(
@@ -642,26 +745,31 @@ describe('shared sources behavior', () => {
       exchanges: [exchange],
     });
 
+    const operation = makeOperation('query', queryOperation, {
+      ...queryOperation.context,
+      requestPolicy: 'network-only',
+    });
+
     const resultOne = jest.fn();
     const resultTwo = jest.fn();
 
-    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+    pipe(client.executeRequestOperation(operation), subscribe(resultOne));
 
     expect(resultOne).toHaveBeenCalledWith({
       data: 1,
-      operation: queryOperation,
+      operation,
     });
 
-    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+    pipe(client.executeRequestOperation(operation), subscribe(resultTwo));
 
     expect(resultTwo).toHaveBeenCalledWith({
       data: 2,
-      operation: queryOperation,
+      operation,
     });
 
     expect(resultOne).toHaveBeenCalledWith({
       data: 2,
-      operation: queryOperation,
+      operation,
     });
   });
 
diff --git a/packages/vue-urql/src/useMutation.test.ts b/packages/vue-urql/src/useMutation.test.ts
index d4e0897d..767e566b 100644
--- a/packages/vue-urql/src/useMutation.test.ts
+++ b/packages/vue-urql/src/useMutation.test.ts
@@ -1,12 +1,9 @@
-jest.mock('vue', () => {
-  const vue = jest.requireActual('vue');
-
-  return {
-    __esModule: true,
-    ...vue,
-    inject: () => client,
-  };
-});
+jest.mock('./useClient.ts', () => ({
+  __esModule: true,
+  ...jest.requireActual('./useClient.ts'),
+  useClient: () => client,
+}));
+
 import { makeSubject, pipe, take, toPromise } from 'wonka';
 import { createClient } from '@urql/core';
 import { useMutation } from './useMutation';
diff --git a/packages/vue-urql/src/useQuery.test.ts b/packages/vue-urql/src/useQuery.test.ts
index ac7a056a..94c6005c 100644
--- a/packages/vue-urql/src/useQuery.test.ts
+++ b/packages/vue-urql/src/useQuery.test.ts
@@ -1,12 +1,8 @@
-jest.mock('vue', () => {
-  const vue = jest.requireActual('vue');
-
-  return {
-    __esModule: true,
-    ...vue,
-    inject: () => client,
-  };
-});
+jest.mock('./useClient.ts', () => ({
+  __esModule: true,
+  ...jest.requireActual('./useClient.ts'),
+  useClient: () => client,
+}));
 
 import { pipe, makeSubject, fromValue, delay } from 'wonka';
 import { createClient } from '@urql/core';
diff --git a/packages/vue-urql/src/useSubscription.test.ts b/packages/vue-urql/src/useSubscription.test.ts
index 3db9b248..a72c1659 100644
--- a/packages/vue-urql/src/useSubscription.test.ts
+++ b/packages/vue-urql/src/useSubscription.test.ts
@@ -1,12 +1,9 @@
-jest.mock('vue', () => {
-  const vue = jest.requireActual('vue');
-
-  return {
-    __esModule: true,
-    ...vue,
-    inject: () => client,
-  };
-});
+jest.mock('./useClient.ts', () => ({
+  __esModule: true,
+  ...jest.requireActual('./useClient.ts'),
+  useClient: () => client,
+}));
+
 import { makeSubject } from 'wonka';
 import { createClient } from '@urql/core';
 import { useSubscription } from './useSubscription';