Repository: FormidableLabs/urql
Pull request #1601 (2021-05-02 22:14:06)

(docs) - Expand willAuthError section with more notes on usage

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

Latest commit: a6306324bebd55b3e94b41fe45b8c9bbca70c386

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/docs/auth-will-error-with-login?filename=.changeset/lovely-shrimps-return.md&value=---%0A%0A---%0A%0A(docs)%20-%20Expand%20willAuthError%20section%20with%20more%20notes%20on%20usage%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/.gitignore b/.gitignore
index dbb094f4..4012b12d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,9 @@ exchanges/*/LICENSE
 tmp/
 dist/
 examples/*/public
-examples/*/yarn.lock
\ No newline at end of file
+examples/*/yarn.lock
+examples/*/ios/
+examples/*/android/
+examples/*/.watchmanconfig
+examples/*/metro.config.js
+examples/*/babel.config.js
\ No newline at end of file
diff --git a/docs/advanced/authentication.md b/docs/advanced/authentication.md
index 7e8b72ab..4fdf7b7a 100644
--- a/docs/advanced/authentication.md
+++ b/docs/advanced/authentication.md
@@ -28,7 +28,7 @@ could have the opportunity to log in again. To do this, we want to clear any per
 from persisted storage, and redirect them to the application home or login page.
 
 **Refresh (optional)** — this is not always implemented; if your API supports it, the
-user will receive both an auth token, and a refresh token. 
+user will receive both an auth token, and a refresh token.
 The auth token is usually valid for a shorter duration of time (e.g. 1 week) than the refresh token
 (e.g. 6 months), and the latter can be used to request a new
 auth token if the auth token has expired. The refresh logic is triggered either when the JWT is known to be invalid (e.g. by decoding it and inspecting the expiry date),
@@ -284,6 +284,43 @@ const willAuthError = ({ authState }) => {
 }
 ```
 
+This can be really useful when we know when our authentication state is invalid and want to prevent
+even sending any operation that we know will fail with an authentication error. However, if we were
+to use this and are logging in our users with a login _mutation_ then the above code will
+unfortunately never let this login mutation through to our GraphQL API.
+
+If we have such a mutation we may need to write a more sophisticated `willAuthError` function like
+the following:
+
+```js
+const willAuthError = ({ operation, authState }) => {
+  if (!authState) {
+    // Detect our login mutation and let this operation through:
+    return !(
+      operation.kind === 'mutation' &&
+      // Here we find any mutation definition with the "login" field
+      operation.query.definitions.some(definition => {
+        return (
+          definition.kind === 'OperationDefinition' &&
+          definition.selectionSet.selections.some(node => {
+            // The field name is just an example, since signup may also be an exception
+            return node.kind === 'Field' && node.name.value === 'login';
+          })
+        );
+      })
+    );
+  } else if (false /* JWT is expired */) {
+    return true;
+  }
+
+  return false;
+};
+```
+
+Alternatively, you may decide to let all operations through if `authState` isn't defined or to allow
+all mutations through. In an application that allows unauthenticated users to perform various
+actions, it's a good idea for us to return `false` when `!authState` applies.
+
 [Read more about `@urql/exchange-auth`'s API in our API docs.](../api/auth-exchange.md)
 
 ## Handling Logout with the Error Exchange
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/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 46a49906..e761427c 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -307,7 +307,7 @@ cacheExchange({
                 data.todos = data.todos.filter(todo => todo.id !== args.id);
                 return data;
               }
-            });
+            );
           });
       },
     },
diff --git a/examples/with-graphcache-pagination/README.md b/examples/with-graphcache-pagination/README.md
index 2b599072..260357c0 100644
--- a/examples/with-graphcache-pagination/README.md
+++ b/examples/with-graphcache-pagination/README.md
@@ -1,4 +1,4 @@
-# Integrating with React
+# Integrating with `@urql/exchange-graphcache`'s cacheExchange pagination
 
 Integrating urql is as simple as:
 
@@ -11,6 +11,7 @@ npm install --save urql graphql
 ```
 
 2. Install [graphcache](https://formidable.com/open-source/urql/docs/graphcache/)
+
 ```sh
 yarn add @urql/exchange-graphcache
 # or
diff --git a/examples/with-multipart/README.md b/examples/with-multipart/README.md
index a8edf0a1..74164302 100644
--- a/examples/with-multipart/README.md
+++ b/examples/with-multipart/README.md
@@ -1,4 +1,4 @@
-# Integrating with React
+# Integrating with `@urql/exchange-multipart-fetch`'s multipartFetchExchange
 
 Integrating urql is as simple as:
 
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/examples/with-react-native/App.js b/examples/with-react-native/App.js
new file mode 100644
index 00000000..eeeea3fe
--- /dev/null
+++ b/examples/with-react-native/App.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { createClient, Provider } from 'urql';
+
+import PokemonList from './src/screens/PokemonList';
+
+const client = createClient({
+  url: 'https://trygql.dev/graphql/basic-pokedex',
+});
+
+const App = () => {
+  return (
+    <Provider value={client}>
+      <PokemonList />
+    </Provider>
+  );
+};
+
+export default App;
diff --git a/examples/with-react-native/README.md b/examples/with-react-native/README.md
new file mode 100644
index 00000000..86824321
--- /dev/null
+++ b/examples/with-react-native/README.md
@@ -0,0 +1,15 @@
+# Integrating with React Native
+
+Integrating urql is as simple as:
+
+1. Install packages [getting started](https://formidable.com/open-source/urql/docs/basics/react-preact/)
+
+```sh
+yarn add urql graphql
+# or
+npm install --save urql graphql
+```
+
+2. Setting up the Client [here](App.js)
+
+3. Execute the Query [here](src/screens/PokemonList.js)
diff --git a/examples/with-react-native/app.json b/examples/with-react-native/app.json
new file mode 100644
index 00000000..296e19a7
--- /dev/null
+++ b/examples/with-react-native/app.json
@@ -0,0 +1,4 @@
+{
+  "name": "withReactNative",
+  "displayName": "withReactNative"
+}
diff --git a/examples/with-react-native/index.js b/examples/with-react-native/index.js
new file mode 100644
index 00000000..9b739329
--- /dev/null
+++ b/examples/with-react-native/index.js
@@ -0,0 +1,9 @@
+/**
+ * @format
+ */
+
+import { AppRegistry } from 'react-native';
+import App from './App';
+import { name as appName } from './app.json';
+
+AppRegistry.registerComponent(appName, () => App);
diff --git a/examples/with-react-native/package.json b/examples/with-react-native/package.json
new file mode 100644
index 00000000..f522d408
--- /dev/null
+++ b/examples/with-react-native/package.json
@@ -0,0 +1,24 @@
+{
+  "name": "withreactnative",
+  "version": "0.0.1",
+  "private": true,
+  "scripts": {
+    "android": "react-native run-android",
+    "ios": "react-native run-ios",
+    "start": "react-native start",
+    "test": "jest",
+    "lint": "eslint ."
+  },
+  "dependencies": {
+    "graphql": "^15.5.0",
+    "react": "17.0.1",
+    "react-native": "0.64.0",
+    "urql": "^2.0.2"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.12.9",
+    "@babel/runtime": "^7.12.5",
+    "babel-jest": "^26.6.3",
+    "metro-react-native-babel-preset": "^0.64.0"
+  }
+}
diff --git a/examples/with-react-native/src/screens/PokemonList.js b/examples/with-react-native/src/screens/PokemonList.js
new file mode 100644
index 00000000..5ad9d1ca
--- /dev/null
+++ b/examples/with-react-native/src/screens/PokemonList.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import { SafeAreaView, StyleSheet, Text, FlatList, View } from 'react-native';
+import { gql, useQuery } from 'urql';
+
+const POKEMONS_QUERY = gql`
+  query Pokemons {
+    pokemons(limit: 10) {
+      id
+      name
+    }
+  }
+`;
+
+const Item = ({ name }) => (
+  <View style={styles.item}>
+    <Text style={styles.title}>{name}</Text>
+  </View>
+);
+
+const PokemonList = () => {
+  const [result] = useQuery({ query: POKEMONS_QUERY });
+
+  const { data, fetching, error } = result;
+
+  const renderItem = ({ item }) => <Item name={item.name} />;
+
+  return (
+    <SafeAreaView style={styles.container}>
+      {fetching && <Text>Loading...</Text>}
+
+      {error && <Text>Oh no... {error.message}</Text>}
+
+      <FlatList
+        data={data?.pokemons}
+        renderItem={renderItem}
+        keyExtractor={item => item.id}
+      />
+    </SafeAreaView>
+  );
+};
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+  item: {
+    backgroundColor: '#dadada',
+    padding: 20,
+    marginVertical: 8,
+    marginHorizontal: 16,
+  },
+  title: {
+    fontSize: 20,
+  },
+});
+
+export default PokemonList;
diff --git a/examples/with-refresh-auth/README.md b/examples/with-refresh-auth/README.md
new file mode 100644
index 00000000..73620e9e
--- /dev/null
+++ b/examples/with-refresh-auth/README.md
@@ -0,0 +1,21 @@
+# Integrating with `@urql/exchange-auth`'s authExchange
+
+Integrating urql is as simple as:
+
+1. Install packages [getting started](https://formidable.com/open-source/urql/docs/basics/react-preact/)
+
+```sh
+yarn add urql graphql
+# or
+npm install --save urql graphql
+```
+
+2. Install [authExchange](https://formidable.com/open-source/urql/docs/advanced/authentication/)
+
+```sh
+yarn add @urql/exchange-auth
+# or
+npm install --save @urql/exchange-auth
+```
+
+3. Setting up the Client and setup the Auth flow [here](src/App.js)
diff --git a/examples/with-refresh-auth/package.json b/examples/with-refresh-auth/package.json
new file mode 100644
index 00000000..c30329e1
--- /dev/null
+++ b/examples/with-refresh-auth/package.json
@@ -0,0 +1,39 @@
+{
+  "name": "react-urql-query",
+  "version": "1.0.0",
+  "private": true,
+  "dependencies": {
+    "@urql/exchange-auth": "^0.1.2",
+    "graphql": "^15.5.0",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "urql": "^2.0.2"
+  },
+  "devDependencies": {
+    "react-scripts": "4.0.3"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test",
+    "eject": "react-scripts eject"
+  },
+  "eslintConfig": {
+    "extends": [
+      "react-app",
+      "react-app/jest"
+    ]
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  }
+}
diff --git a/examples/with-refresh-auth/src/App.js b/examples/with-refresh-auth/src/App.js
new file mode 100644
index 00000000..466053b4
--- /dev/null
+++ b/examples/with-refresh-auth/src/App.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Provider } from 'urql';
+
+import client from './client';
+import Home from './pages/Home';
+
+function App() {
+  return (
+    <Provider value={client}>
+      <Home />
+    </Provider>
+  );
+}
+
+export default App;
diff --git a/examples/with-refresh-auth/src/auth/Store.js b/examples/with-refresh-auth/src/auth/Store.js
new file mode 100644
index 00000000..5f0ab394
--- /dev/null
+++ b/examples/with-refresh-auth/src/auth/Store.js
@@ -0,0 +1,19 @@
+const TOKEN_KEY = 'token';
+const REFRESH_TOKEN_KEY = 'refresh_token';
+
+export const saveAuthData = ({ token, refreshToken }) => {
+  localStorage.setItem(TOKEN_KEY, token);
+  localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
+};
+
+export const getToken = () => {
+  return localStorage.getItem(TOKEN_KEY);
+};
+
+export const getRefreshToken = () => {
+  return localStorage.getItem(REFRESH_TOKEN_KEY);
+};
+
+export const clearStorage = () => {
+  localStorage.clear();
+};
diff --git a/examples/with-refresh-auth/src/client/index.js b/examples/with-refresh-auth/src/client/index.js
new file mode 100644
index 00000000..edf6d36f
--- /dev/null
+++ b/examples/with-refresh-auth/src/client/index.js
@@ -0,0 +1,112 @@
+import {
+  createClient,
+  dedupExchange,
+  fetchExchange,
+  cacheExchange,
+  gql,
+} from 'urql';
+import { makeOperation } from '@urql/core';
+import { authExchange } from '@urql/exchange-auth';
+import {
+  getRefreshToken,
+  getToken,
+  saveAuthData,
+  clearStorage,
+} from '../auth/Store';
+
+const REFRESH_TOKEN_MUTATION = gql`
+  mutation RefreshCredentials($refreshToken: String!) {
+    refreshCredentials(refreshToken: $refreshToken) {
+      refreshToken
+      token
+    }
+  }
+`;
+
+const getAuth = async ({ authState, mutate }) => {
+  if (!authState) {
+    const token = getToken();
+    const refreshToken = getRefreshToken();
+
+    if (token && refreshToken) {
+      return { token, refreshToken };
+    }
+
+    return null;
+  }
+
+  const result = await mutate(REFRESH_TOKEN_MUTATION, {
+    refreshToken: authState.refreshToken,
+  });
+
+  if (result.data?.refreshCredentials) {
+    saveAuthData(result.data.refreshCredentials);
+
+    return result.data.refreshCredentials;
+  }
+
+  // This is where auth has gone wrong and we need to clean up and redirect to a login page
+  clearStorage();
+  window.location.reload();
+
+  return null;
+};
+
+const addAuthToOperation = ({ authState, operation }) => {
+  if (!authState || !authState.token) {
+    return operation;
+  }
+
+  const fetchOptions =
+    typeof operation.context.fetchOptions === 'function'
+      ? operation.context.fetchOptions()
+      : operation.context.fetchOptions || {};
+
+  return makeOperation(operation.kind, operation, {
+    ...operation.context,
+    fetchOptions: {
+      ...fetchOptions,
+      headers: {
+        ...fetchOptions.headers,
+        Authorization: `Bearer ${authState.token}`,
+      },
+    },
+  });
+};
+
+const didAuthError = ({ error }) => {
+  return error.graphQLErrors.some(e => e.extensions?.code === 'UNAUTHORIZED');
+};
+
+const willAuthError = ({ operation, authState }) => {
+  if (!authState) {
+    // Detect our login mutation and let this operation through:
+    return (
+      operation.kind !== 'mutation' ||
+      // Here we find any mutation definition with the "signin" field
+      !operation.query.definitions.some(definition => {
+        return (
+          definition.kind === 'OperationDefinition' &&
+          definition.selectionSet.selections.some(node => {
+            // The field name is just an example, since register may also be an exception
+            return node.kind === 'Field' && node.name.value === 'signin';
+          })
+        );
+      })
+    );
+  }
+
+  return true;
+};
+
+const client = createClient({
+  url: 'https://trygql.dev/graphql/web-collections',
+  exchanges: [
+    dedupExchange,
+    cacheExchange,
+    authExchange({ getAuth, addAuthToOperation, didAuthError, willAuthError }),
+    fetchExchange,
+  ],
+});
+
+export default client;
diff --git a/examples/with-refresh-auth/src/index.css b/examples/with-refresh-auth/src/index.css
new file mode 100644
index 00000000..ec2585e8
--- /dev/null
+++ b/examples/with-refresh-auth/src/index.css
@@ -0,0 +1,13 @@
+body {
+  margin: 0;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+    sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+    monospace;
+}
diff --git a/examples/with-refresh-auth/src/index.js b/examples/with-refresh-auth/src/index.js
new file mode 100644
index 00000000..62adcf3c
--- /dev/null
+++ b/examples/with-refresh-auth/src/index.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import './index.css';
+import App from './App';
+
+ReactDOM.render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+  document.getElementById('root')
+);
+
diff --git a/examples/with-refresh-auth/src/pages/Home.js b/examples/with-refresh-auth/src/pages/Home.js
new file mode 100644
index 00000000..5699fcaf
--- /dev/null
+++ b/examples/with-refresh-auth/src/pages/Home.js
@@ -0,0 +1,28 @@
+import React, { useEffect, useState } from 'react';
+
+import { getToken, saveAuthData } from '../auth/Store';
+import Profile from './Profile';
+import LoginForm from './LoginForm';
+
+const Home = () => {
+  const [isLoggedIn, setIsLoggedIn] = useState(false);
+
+  const onLoginSuccess = auth => {
+    saveAuthData(auth);
+    setIsLoggedIn(true);
+  };
+
+  useEffect(() => {
+    if (getToken()) {
+      setIsLoggedIn(true);
+    }
+  }, []);
+
+  return isLoggedIn ? (
+    <Profile />
+  ) : (
+    <LoginForm onLoginSuccess={onLoginSuccess} />
+  );
+};
+
+export default Home;
diff --git a/examples/with-refresh-auth/src/pages/LoginForm.js b/examples/with-refresh-auth/src/pages/LoginForm.js
new file mode 100644
index 00000000..920b1fed
--- /dev/null
+++ b/examples/with-refresh-auth/src/pages/LoginForm.js
@@ -0,0 +1,57 @@
+import React, { useEffect, useState } from 'react';
+import { gql, useMutation } from 'urql';
+
+const LOGIN_MUTATION = gql`
+  mutation Signin($input: LoginInput!) {
+    signin(input: $input) {
+      refreshToken
+      token
+    }
+  }
+`;
+
+const LoginForm = ({ onLoginSuccess }) => {
+  const [password, setPassword] = useState('');
+  const [username, setUsername] = useState('');
+
+  const [loginResult, login] = useMutation(LOGIN_MUTATION);
+
+  const { data, fetching, error } = loginResult;
+
+  const onUsernameChange = evt => {
+    setUsername(evt.target.value);
+  };
+
+  const onPasswordChange = evt => {
+    setPassword(evt.target.value);
+  };
+
+  const onSubmit = evt => {
+    evt.preventDefault();
+    login({ input: { username, password } });
+  };
+
+  useEffect(() => {
+    if (data && data.signin) {
+      onLoginSuccess(data.signin);
+    }
+  }, [onLoginSuccess, data]);
+
+  if (fetching) {
+    return <p>loading...</p>;
+  }
+
+  return (
+    <form onSubmit={onSubmit}>
+      {error && <p>Oh no... {error.message}</p>}
+
+      <input type="text" onChange={onUsernameChange} />
+
+      <input type="password" onChange={onPasswordChange} />
+
+      <input type="submit" title="login" />
+    </form>
+  );
+};
+
+export default LoginForm;
diff --git a/examples/with-refresh-auth/src/pages/Profile.js b/examples/with-refresh-auth/src/pages/Profile.js
new file mode 100644
index 00000000..6813ab5f
--- /dev/null
+++ b/examples/with-refresh-auth/src/pages/Profile.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { gql, useQuery } from 'urql';
+
+const PROFILE_QUERY = gql`
+  query Profile {
+    me {
+      id
+      username
+      createdAt
+    }
+  }
+`;
+
+const Profile = () => {
+  const [result] = useQuery({ query: PROFILE_QUERY });
+
+  const { data, fetching, error } = result;
+
+  return (
+    <div>
+      {fetching && <p>Loading...</p>}
+
+      {error && <p>Oh no... {error.message}</p>}
+
+      {data?.me && (
+        <>
+          <p>profile data</p>
+          <p>id: {data.me.id}</p>
+          <p>username: {data.me.username}</p>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default Profile;
diff --git a/package.json b/package.json
index 42692867..0fa032c0 100644
--- a/package.json
+++ b/package.json
@@ -25,8 +25,7 @@
   },
   "eslintIgnore": [
     "packages/site/dist-prod",
-    "docs",
-    "examples"
+    "docs"
   ],
   "prettier": {
     "singleQuote": true,
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';