Repository: FormidableLabs/urql
Pull request #1602 (2021-05-03 08:08:36)

(next) - allow subsequent statically generated pages to hydrate the ssr-cache
Resolves https://github.com/FormidableLabs/urql/issues/1567

## Summary

Next will inject data available for a route as JSON, similar to how it does so for the first-page-serve, this is why we have to add a special else branch to account for this scenario.

## Set of changes

- Add check for when a client-side transition introduces new data to hydrate into the SSG page.


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

Latest commit: 26be73914b44f6c391fa2f2df2bd1e97fdf5a285

**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  |
| --------- | ----- |
| next-urql | Minor |

</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/next-subsequent-ssg-pages?filename=.changeset/wild-shoes-guess.md&value=---%0A%22next-urql%22%3A%20patch%0A---%0A%0A(next)%20-%20allow%20subsequent%20statically%20generated%20pages%20to%20hydrate%20the%20ssr-cache%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/.changeset/witty-spiders-matter.md b/.changeset/witty-spiders-matter.md
new file mode 100644
index 00000000..3a0fb2c8
--- /dev/null
+++ b/.changeset/witty-spiders-matter.md
@@ -0,0 +1,5 @@
+---
+'next-urql': minor
+---
+
+Allow subsequent static-pages to hydrate the cache
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/examples/with-next/package.json b/examples/with-next/package.json
index d8dcc24d..caf7a528 100644
--- a/examples/with-next/package.json
+++ b/examples/with-next/package.json
@@ -11,6 +11,7 @@
     "urql": "^2.0.2"
   },
   "scripts": {
+    "dev": "next dev",
     "start": "next",
     "build": "next build"
   }
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/next-urql/src/with-urql-client.ts b/packages/next-urql/src/with-urql-client.ts
index 57128576..30e76fcb 100644
--- a/packages/next-urql/src/with-urql-client.ts
+++ b/packages/next-urql/src/with-urql-client.ts
@@ -55,6 +55,8 @@ export function withUrqlClient(
         if (!ssr || typeof window === 'undefined') {
           // We want to force the cache to hydrate, we do this by setting the isClient flag to true
           ssr = ssrExchange({ initialState: urqlServerState, isClient: true });
+        } else if (ssr && typeof window !== 'undefined') {
+          ssr.restoreData(urqlServerState);
         }
 
         const clientConfig = getClientConfig(ssr);
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';