Repository: FormidableLabs/urql
Pull request #1515 (2021-04-04 14:49:36)

(core) - Make Client more forgiving by sharing result sources that emit cached results
## Summary

At the risk of being called edgy, this proposed change layers a third cache on top of the `Client`. Apart from the usual exchange cache (default `cacheExchange` or Graphcache) and the React bindings' suspense/concurrent cache, this third cache is just a minor one to share results across all sources.

This change replaces the `Client`'s semaphore with a shared binary semaphore that only stores the result source for a given operation. Hence, an active operation now has a single source that it uses for all subscribers. Apart from the shared source on each subscription it will still emit a new operation. However, all sources for a single subscription now share an underlying source. This source ensures that all operations see the exact same result (which was only implicitly guaranteed before) and each new subscriber will receive the last previously known result if its operation doesn't immediately yield a first result.

This means that it'll be harder to trip up when layering a lot of logic with shared operations that have multiple concurrent subscribers and multiple concurrent uses of `cache-and-network` request policies.

## Set of changes

- Replace `activeOperations` type with a `Map<number, ActiveOperation>`
- Store a result source for each query/subscription source that's shared and cleans itself up
- Keep around an "active cached value" for each source that's the last known result
- Clear the `activeOperations` when it ends
- Use the "active cached value" to emit a first value for new subscribers when they start and don't receive a synchronous result immediately
- Ensure that each new subscriber issues a fresh operation

**The risk associated with this change right now is that the `requestPolicy` may be disregarded. We have to investigate whether a `network-only` operation for instance still shows the expected bindings behaviour. Although, this hasn't been doing what people thought anyway.**


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

Latest commit: 453881208d07cf726b81b83eb4ce4b69bfc30b73

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

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

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

</details>

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

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/single-source-behavior?filename=.changeset/dull-tigers-grin.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0A(core)%20-%20Make%20Client%20more%20forgiving%20by%20sharing%20result%20sources%20that%20emit%20cached%20results%0A)


kitten: I'd say from a "proof of concept" perspective, this is ready and working now. I'm not quite happy with the implementation in `Client` yet, but the tests should be able to guide us to a better implementation as possible and needed. The difficulty here is that we'd like result sources _not_ to replay prior results if the cache offers a result up immediately/synchronously, which makes encapsulating this logic difficult, as the cache would emit a result in `onStart` which makes this less composable. So either it won't be any prettier/elegant or we can change the timing of this a little.

We also need to test whether this behaviour actually improves the intuitiveness of `cache-and-network` and `network-only` in some apps. It does make `network-only` behave a little more like `cache-and-network`, so we may want to instead ignore those as needed and not replay results if `network-only` is used. The question is whether the `stale: true` behaviour is a little too aggressive in that case but we have an existing test case for that too, so we can adjust as needed. My hunch is that `network-only` should not replay any results.

Patch:
diff --git a/.changeset/curvy-bobcats-fry.md b/.changeset/curvy-bobcats-fry.md
new file mode 100644
index 00000000..2098d611
--- /dev/null
+++ b/.changeset/curvy-bobcats-fry.md
@@ -0,0 +1,22 @@
+---
+'@urql/exchange-auth': patch
+'@urql/exchange-execute': patch
+'@urql/exchange-graphcache': patch
+'@urql/exchange-multipart-fetch': patch
+'@urql/exchange-persisted-fetch': patch
+'@urql/exchange-populate': patch
+'@urql/exchange-refocus': patch
+'@urql/exchange-request-policy': patch
+'@urql/exchange-retry': patch
+'@urql/exchange-suspense': patch
+'@urql/core': patch
+'@urql/introspection': patch
+'next-urql': patch
+'@urql/preact': patch
+'urql': patch
+'@urql/storybook-addon': patch
+'@urql/svelte': patch
+'@urql/vue': patch
+---
+
+Remove closure-compiler from the build step
diff --git a/.changeset/great-scissors-boil.md b/.changeset/great-scissors-boil.md
new file mode 100644
index 00000000..5f7e95b7
--- /dev/null
+++ b/.changeset/great-scissors-boil.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix list items being returned as `null` even for non-nullable lists, when the entities are missing in the cache. This could happen when a resolver was added returning entities or their keys. This behaviour is now (correctly) only applied to partial results with schema awareness.
diff --git a/.changeset/lovely-hounds-tap.md b/.changeset/lovely-hounds-tap.md
new file mode 100644
index 00000000..a69d9e81
--- /dev/null
+++ b/.changeset/lovely-hounds-tap.md
@@ -0,0 +1,7 @@
+---
+'@urql/introspection': minor
+---
+
+Add options to `@urql/introspection`'s `minifyIntrospectionQuery` allowing the inclusion of more
+information into the minified schema as needed, namely `includeScalars`, `includeEnums`,
+`includeInputs`, and `includeDirectives`.
diff --git a/.gitignore b/.gitignore
index 886e30e2..dbb094f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,5 @@ exchanges/*/LICENSE
 # TODO: Figure out how to remove these:
 tmp/
 dist/
+examples/*/public
+examples/*/yarn.lock
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d906d40..d53206fd 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
   <br />
   <br />
   <a href="https://circleci.com/gh/FormidableLabs/urql">
-    <img alt="Test Status" src="https://badgen.net/github/status/FormidableLabs/urql/main/ci/circleci?label=circleci" />
+    <img alt="CI Status" src="https://github.com/FormidableLabs/urql/actions/workflows/ci.yml/badge.svg?branch=main" />
   </a>
   <a href="https://github.com/FormidableLabs/urql#maintenance-status">
     <img alt="Maintenance Status" src="https://badgen.net/badge/maintenance/active/green" />
diff --git a/docs/advanced/retry-operations.md b/docs/advanced/retry-operations.md
index 64151d82..0f08d266 100644
--- a/docs/advanced/retry-operations.md
+++ b/docs/advanced/retry-operations.md
@@ -79,9 +79,7 @@ const client = createClient({
     cacheExchange,
     retryExchange({
       retryIf: error => {
-        if ((error && error.graphQLErrors.length > 0) || error.networkError) {
-          return true;
-        }
+        return !!(error.graphQLErrors.length > 0 || error.networkError);
       },
     }),
     fetchExchange,
diff --git a/docs/advanced/server-side-rendering.md b/docs/advanced/server-side-rendering.md
index b6a38cb3..fdcf14b3 100644
--- a/docs/advanced/server-side-rendering.md
+++ b/docs/advanced/server-side-rendering.md
@@ -185,9 +185,9 @@ To set up `next-urql`, first we'll install `next-urql` with `react-is` and `urql
 peer dependencies:
 
 ```sh
-yarn add next-urql react-is urql
+yarn add next-urql react-is urql graphql
 # or
-npm install --save next-urql react-is urql
+npm install --save next-urql react-is urql graphql
 ```
 
 The peer dependency on `react-is` is inherited from `react-ssr-prepass` requiring it.
@@ -204,6 +204,7 @@ Optimization"](https://nextjs.org/docs/advanced-features/automatic-static-optimi
 // pages/index.js
 import React from 'react';
 import Head from 'next/head';
+import { useQuery } from "urql";
 import { withUrqlClient } from 'next-urql';
 
 const Index = () => {
diff --git a/docs/api/urql.md b/docs/api/urql.md
index 66b4b4d6..a886ccc8 100644
--- a/docs/api/urql.md
+++ b/docs/api/urql.md
@@ -14,7 +14,7 @@ Accepts a single required options object as an input with the following properti
 | `query`         | `string \| DocumentNode` | The query to be executed. Accepts as a plain string query or GraphQL DocumentNode.                       |
 | `variables`     | `?object`                | The variables to be used with the GraphQL request.                                                       |
 | `requestPolicy` | `?RequestPolicy`         | An optional [request policy](./core.md#requestpolicy) that should be used specifying the cache strategy. |
-| `pause`         | `?boolean`               | A boolean flag instructing [execution to be paused](../basics/queries.md#pausing-usequery).              |
+| `pause`         | `?boolean`               | A boolean flag instructing [execution to be paused](../basics/react-preact.md#pausing-usequery).              |
 | `context`       | `?object`                | Holds the contextual information for the query.                                                          |
 
 This hook returns a tuple of the shape `[result, executeQuery]`.
diff --git a/docs/basics/svelte.md b/docs/basics/svelte.md
index ac37f896..58c14b2a 100644
--- a/docs/basics/svelte.md
+++ b/docs/basics/svelte.md
@@ -31,6 +31,17 @@ all `@urql/svelte` packages will define a range of compatible versions of `graph
 for breaking changes in the future however, in which case your package manager may warn you about
 `graphql` being out of the defined peer dependency range.
 
+Note: if using Vite as your bundler, you might stumble upon the error `Function called outside component initialization`, which will prevent the page from loading. To fix it, you must add `@urql/svelte` to Vite's configuration property [`optimizeDeps.exclude`](https://vitejs.dev/config/#dep-optimization-options):
+
+```js
+{
+  optimizeDeps: {
+    exclude: ['@urql/svelte'],
+  }
+  // other properties
+}
+```
+
 ### Setting up the `Client`
 
 The `@urql/svelte` package exports a method called `createClient` which we can use to create
diff --git a/examples/with-next/README.md b/examples/with-next/README.md
new file mode 100644
index 00000000..bb29b2b2
--- /dev/null
+++ b/examples/with-next/README.md
@@ -0,0 +1,48 @@
+# Integrating with Next
+
+## getInitialProps
+
+This is the output you'll get when you're using `{ ssr: true }`, this way urql will try to automate
+as much for you as it possibly can by using a [`prepass`](https://github.com/FormidableLabs/react-ssr-prepass)
+this means that every `useQuery` used in your virtual-dom will be ran, the data will be collected on the server
+and hydrated on the client.
+
+> NOTE: to reduce performance complexities try to keep this to top-level renders as this can amount to waterfalls.
+
+## getStaticProps
+
+This requires some manual work, when we look at [`static.js`](./pages/static.js) we can see that we define our own
+`getStaticProps` method, this because these methods are only `user-facing`. When doing a `yarn next build` we'll need to
+ensure that the server we're targetting is running so we can successfully execute the static prerender.
+
+## getServerSideProps
+
+This requires some manual work, when we look at [`server.js`](./pages/server.js) we can see that we define our own
+`getServerSideProps` method, this because these methods are only `user-facing`.
+
+## Output
+
+We can see that our `/` and `/server` routes are rendered on the server and `/static` is statically prerendered.
+
+```
+Page                                                           Size     First Load JS
+┌ λ /                                                          4.98 kB          90 kB
+├   /_app                                                      0 B              85 kB
+├ ○ /404                                                       3.46 kB        88.5 kB
+├ λ /api/graphql                                               0 B              85 kB
+├ λ /server                                                    878 B          85.9 kB
+└ ● /static                                                    895 B          85.9 kB
++ First Load JS shared by all                                  85 kB
+  ├ chunks/d8c192fcf6e34535672c13f111ef41e3832b265d.d03071.js  17.4 kB
+  ├ chunks/f6078781a05fe1bcb0902d23dbbb2662c8d200b3.6a2b27.js  13.3 kB
+  ├ chunks/framework.4b1bec.js                                 41.8 kB
+  ├ chunks/main.3d1d43.js                                      7.14 kB
+  ├ chunks/pages/_app.92bde8.js                                4.68 kB
+  └ chunks/webpack.50bee0.js                                   751 B
+
+
+λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
+○  (Static)  automatically rendered as static HTML (uses no initial props)
+●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
+   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)
+```
diff --git a/examples/with-next/package.json b/examples/with-next/package.json
new file mode 100644
index 00000000..d8dcc24d
--- /dev/null
+++ b/examples/with-next/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "next-get-static-props",
+  "version": "1.0.0",
+  "private": true,
+  "dependencies": {
+    "graphql": "^15.5.0",
+    "next": "10.1.2",
+    "next-urql": "^3.0.1",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "urql": "^2.0.2"
+  },
+  "scripts": {
+    "start": "next",
+    "build": "next build"
+  }
+}
diff --git a/examples/with-next/pages/_app.js b/examples/with-next/pages/_app.js
new file mode 100644
index 00000000..4c753e27
--- /dev/null
+++ b/examples/with-next/pages/_app.js
@@ -0,0 +1,10 @@
+import { withUrqlClient } from "next-urql";
+
+const App = ({ Component, pageProps }) => <Component {...pageProps} />;
+
+export default withUrqlClient(
+  () => ({
+    url: "https://trygql.dev/graphql/basic-pokedex"
+  }),
+  { ssr: false }
+)(App);
diff --git a/examples/with-next/pages/index.js b/examples/with-next/pages/index.js
new file mode 100644
index 00000000..71c39366
--- /dev/null
+++ b/examples/with-next/pages/index.js
@@ -0,0 +1,40 @@
+import { initUrqlClient, withUrqlClient } from "next-urql";
+import {
+  ssrExchange,
+  dedupExchange,
+  cacheExchange,
+  fetchExchange,
+  useQuery,
+  gql
+} from "urql";
+
+const POKEMONS_QUERY = gql`
+  query {
+    pokemons(limit: 10) {
+      id
+      name
+    }
+  }
+`;
+
+function Index() {
+  const [res] = useQuery({ query: POKEMONS_QUERY });
+
+  return (
+    <div>
+      <h1>Static</h1>
+      {res.data.pokemons.map((pokemon) => (
+        <div key={pokemon.id}>
+          {pokemon.id} - {pokemon.name}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export default withUrqlClient(
+  () => ({
+    url: "https://trygql.dev/graphql/basic-pokedex"
+  }),
+  { ssr: true }
+)(Index);
diff --git a/examples/with-next/pages/server.js b/examples/with-next/pages/server.js
new file mode 100644
index 00000000..8844f6a4
--- /dev/null
+++ b/examples/with-next/pages/server.js
@@ -0,0 +1,54 @@
+import { initUrqlClient } from "next-urql";
+import {
+  ssrExchange,
+  dedupExchange,
+  cacheExchange,
+  fetchExchange,
+  useQuery
+  gql
+} from "urql";
+
+const POKEMONS_QUERY = gql`
+  query {
+    pokemons(limit: 10) {
+      id
+      name
+    }
+  }
+`;
+
+function Server() {
+  const [res] = useQuery({ query: POKEMONS_QUERY });
+
+  return (
+    <div>
+      <h1>Server-side render</h1>
+      {res.data.pokemons.map((pokemon) => (
+        <div key={pokemon.id}>
+          {pokemon.id} - {pokemon.name}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export async function getServerSideProps() {
+  const ssrCache = ssrExchange({ isClient: false });
+  const client = initUrqlClient({
+    url: "https://trygql.dev/graphql/basic-pokedex",
+    exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange]
+  }, false);
+
+  // This query is used to populate the cache for the query
+  // used on this page.
+  await client.query(POKEMONS_QUERY).toPromise();
+
+  return {
+    props: {
+      // urqlState is a keyword here so withUrqlClient can pick it up.
+      urqlState: ssrCache.extractData()
+    },
+  };
+}
+
+export default Server;
diff --git a/examples/with-next/pages/static.js b/examples/with-next/pages/static.js
new file mode 100644
index 00000000..bf7569cb
--- /dev/null
+++ b/examples/with-next/pages/static.js
@@ -0,0 +1,54 @@
+import { initUrqlClient } from "next-urql";
+import {
+  ssrExchange,
+  dedupExchange,
+  cacheExchange,
+  fetchExchange,
+  useQuery,
+  gql
+} from "urql";
+
+const POKEMONS_QUERY = gql`
+  query {
+    pokemons(limit: 10) {
+      id
+      name
+    }
+  }
+`;
+
+function Static() {
+  const [res] = useQuery({ query: POKEMONS_QUERY });
+
+  return (
+    <div>
+      <h1>Static</h1>
+      {res.data.pokemons.map((pokemon) => (
+        <div key={pokemon.id}>
+          {pokemon.id} - {pokemon.name}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export async function getStaticProps() {
+  const ssrCache = ssrExchange({ isClient: false });
+  const client = initUrqlClient({
+    url: "https://trygql.dev/graphql/basic-pokedex",
+    exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange]
+  }, false);
+
+  // This query is used to populate the cache for the query
+  // used on this page.
+  await client.query(POKEMONS_QUERY).toPromise();
+
+  return {
+    props: {
+      // urqlState is a keyword here so withUrqlClient can pick it up.
+      urqlState: ssrCache.extractData()
+    },
+  };
+}
+
+export default Static;
diff --git a/examples/with-react/README.md b/examples/with-react/README.md
new file mode 100644
index 00000000..d3da29c4
--- /dev/null
+++ b/examples/with-react/README.md
@@ -0,0 +1,15 @@
+# Integrating with React
+
+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](src/App.js)
+
+3. Execute the Query [here](src/pages/PokemonList.js)
diff --git a/examples/with-react/package.json b/examples/with-react/package.json
new file mode 100644
index 00000000..107c24e0
--- /dev/null
+++ b/examples/with-react/package.json
@@ -0,0 +1,38 @@
+{
+  "name": "react-urql-query",
+  "version": "1.0.0",
+  "private": true,
+  "dependencies": {
+    "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-react/src/App.js b/examples/with-react/src/App.js
new file mode 100644
index 00000000..fa525b74
--- /dev/null
+++ b/examples/with-react/src/App.js
@@ -0,0 +1,17 @@
+import { createClient, Provider } from "urql";
+
+import PokemonList from "./pages/PokemonList";
+
+const client = createClient({
+  url: "https://trygql.dev/graphql/basic-pokedex",
+});
+
+function App() {
+  return (
+    <Provider value={client}>
+      <PokemonList />
+    </Provider>
+  );
+}
+
+export default App;
diff --git a/examples/with-react/src/index.css b/examples/with-react/src/index.css
new file mode 100644
index 00000000..ec2585e8
--- /dev/null
+++ b/examples/with-react/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-react/src/index.js b/examples/with-react/src/index.js
new file mode 100644
index 00000000..62adcf3c
--- /dev/null
+++ b/examples/with-react/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-react/src/pages/PokemonList.js b/examples/with-react/src/pages/PokemonList.js
new file mode 100644
index 00000000..84743c0f
--- /dev/null
+++ b/examples/with-react/src/pages/PokemonList.js
@@ -0,0 +1,35 @@
+import React from "react";
+import { gql, useQuery } from "urql";
+
+const POKEMONS_QUERY = gql`
+  query Pokemons {
+    pokemons(limit: 10) {
+      id
+      name
+    }
+  }
+`;
+
+const PokemonList = () => {
+  const [result] = useQuery({ query: POKEMONS_QUERY });
+
+  const { data, fetching, error } = result;
+
+  return (
+    <div>
+      {fetching && <p>Loading...</p>}
+
+      {error && <p>Oh no... {error.message}</p>}
+
+      {data && (
+        <ul>
+          {data.pokemons.map((pokemon) => (
+            <li key={pokemon.id}>{pokemon.name}</li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+};
+
+export default PokemonList;
diff --git a/examples/with-retry/README.md b/examples/with-retry/README.md
new file mode 100644
index 00000000..f950977c
--- /dev/null
+++ b/examples/with-retry/README.md
@@ -0,0 +1,15 @@
+# Integrating `@urql/exchange-retry`’s retryExchange
+
+Integrating urql is as simple as:
+
+1.  Install packages
+
+yarn add urql @urql/exchange-retry graphql
+
+# or
+
+npm install --save urql @urql/exchange-retry graphql
+
+2.  Setting up the Client and adding the `retryExchange` [here](src/App.js)
+
+3.  Execute the Query [here](src/pages/PokemonList.js)
diff --git a/examples/with-retry/package.json b/examples/with-retry/package.json
new file mode 100644
index 00000000..d194d87b
--- /dev/null
+++ b/examples/with-retry/package.json
@@ -0,0 +1,39 @@
+{
+  "name": "urlq-retry-query",
+  "version": "1.0.0",
+  "private": true,
+  "dependencies": {
+    "@urql/exchange-retry": "^0.2.0",
+    "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-retry/src/App.js b/examples/with-retry/src/App.js
new file mode 100644
index 00000000..ce753c08
--- /dev/null
+++ b/examples/with-retry/src/App.js
@@ -0,0 +1,27 @@
+import { createClient, fetchExchange, Provider } from "urql";
+import { retryExchange } from '@urql/exchange-retry';
+
+import Color from "./pages/Color";
+
+const client = createClient({
+  url: "https://trygql.dev/graphql/intermittent-colors",
+  exchanges: [
+    retryExchange({
+      maxNumberAttempts: 5,
+      retryIf: error => {
+        return error.graphQLErrors.length || error.networkError;
+      }
+    }), // Use the retryExchange factory to add a new exchange,
+    fetchExchange
+  ],
+});
+
+function App() {
+  return (
+    <Provider value={client}>
+      <Color />
+    </Provider>
+  );
+}
+
+export default App;
diff --git a/examples/with-retry/src/index.css b/examples/with-retry/src/index.css
new file mode 100644
index 00000000..ec2585e8
--- /dev/null
+++ b/examples/with-retry/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-retry/src/index.js b/examples/with-retry/src/index.js
new file mode 100644
index 00000000..6832e783
--- /dev/null
+++ b/examples/with-retry/src/index.js
@@ -0,0 +1,11 @@
+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-retry/src/pages/Color.js b/examples/with-retry/src/pages/Color.js
new file mode 100644
index 00000000..747436c4
--- /dev/null
+++ b/examples/with-retry/src/pages/Color.js
@@ -0,0 +1,37 @@
+import React from "react";
+import { gql, useQuery } from "urql";
+
+const RANDOM_COLOR_QUERY = gql`
+  query RandomColor {
+    randomColor {
+      name
+      hex
+    }
+  }
+`;
+
+const RandomColorDisplay = () => {
+  const [result] = useQuery({ query: RANDOM_COLOR_QUERY });
+
+  const { data, fetching, error } = result;
+
+  return (
+    <div>
+      {fetching && <p>Loading...</p>}
+
+      {error && <p>Oh no... {error.message}</p>}
+
+      {data && (
+        <div style={{ backgroundColor: data.randomColor.hex }}>
+          {data.randomColor.name}
+        </div>
+      )}
+      
+      {result.operation && (
+        <p>We retried {result.operation.context.retryCount} times to get a result without an error.</p>
+      )}
+    </div>
+  );
+};
+
+export default RandomColorDisplay;
diff --git a/exchanges/graphcache/default-storage/package.json b/exchanges/graphcache/default-storage/package.json
index e7d59cd5..5f6d0011 100644
--- a/exchanges/graphcache/default-storage/package.json
+++ b/exchanges/graphcache/default-storage/package.json
@@ -1,6 +1,7 @@
 {
   "name": "urql-exchange-graphcache-default-storage",
   "private": true,
+  "version": "0.0.0",
   "main": "../dist/urql-exchange-graphcache-default-storage",
   "module": "../dist/urql-exchange-graphcache-default-storage.mjs",
   "types": "../dist/types/default-storage/index.d.ts",
@@ -15,7 +16,7 @@
     "./package.json": "./package.json"
   },
   "dependencies": {
-    "@urql/core": ">=1.16.0",
+    "@urql/core": ">=2.0.0",
     "wonka": "^4.0.14"
   }
 }
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index cf0085ed..0203b076 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -447,8 +447,9 @@ const resolveResolverResult = (
     const { store } = ctx;
     // Check whether values of the list may be null; for resolvers we assume
     // that they can be, since it's user-provided data
-    const _isListNullable =
-      !store.schema || isListNullable(store.schema, typename, fieldName);
+    const _isListNullable = store.schema
+      ? isListNullable(store.schema, typename, fieldName)
+      : false;
     const data = new Array(result.length);
     for (let i = 0, l = result.length; i < l; i++) {
       // Add the current index to the walked path before reading the field's value
@@ -509,8 +510,9 @@ const resolveLink = (
 ): DataField | undefined => {
   if (Array.isArray(link)) {
     const { store } = ctx;
-    const _isListNullable =
-      store.schema && isListNullable(store.schema, typename, fieldName);
+    const _isListNullable = store.schema
+      ? isListNullable(store.schema, typename, fieldName)
+      : false;
     const newLink = new Array(link.length);
     for (let i = 0, l = link.length; i < l; i++) {
       // Add the current index to the walked path before reading the field's value
diff --git a/package.json b/package.json
index 6c3d506b..394bbd05 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,6 @@
     "react-is": "^17.0.1"
   },
   "devDependencies": {
-    "@ampproject/rollup-plugin-closure-compiler": "^0.26.0",
     "@babel/core": "^7.12.13",
     "@babel/plugin-transform-object-assign": "^7.12.13",
     "@babel/plugin-transform-react-jsx": "^7.12.13",
@@ -60,7 +59,6 @@
     "@types/jest": "^26.0.20",
     "@typescript-eslint/eslint-plugin": "^4.22.0",
     "@typescript-eslint/parser": "^4.22.0",
-    "babel-plugin-closure-elimination": "^1.3.2",
     "babel-plugin-modular-graphql": "1.0.1",
     "babel-plugin-transform-async-to-promises": "^0.8.15",
     "dotenv": "^8.2.0",
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 8951950e..7c25a63b 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -41,6 +41,7 @@ import {
 import {
   createRequest,
   withPromise,
+  replayOnStart,
   maskTypename,
   noop,
   makeOperation,
@@ -66,10 +67,6 @@ export interface ClientOptions {
   maskTypename?: boolean;
 }
 
-interface ActiveOperations {
-  [operationKey: string]: number;
-}
-
 export const createClient = (opts: ClientOptions) => new Client(opts);
 
 /** The URQL application-wide client library. Each execute method starts a GraphQL request and returns a stream of results. */
@@ -93,7 +90,7 @@ export class Client {
   dispatchOperation: (operation?: Operation | void) => void;
   operations$: Source<Operation>;
   results$: Source<OperationResult>;
-  activeOperations = Object.create(null) as ActiveOperations;
+  activeOperations: Map<number, Source<OperationResult>> = new Map();
   queue: Operation[] = [];
 
   constructor(opts: ClientOptions) {
@@ -138,7 +135,7 @@ export class Client {
       // operation's exchange results
       if (
         operation.kind === 'mutation' ||
-        (this.activeOperations[operation.key] || 0) > 0
+        this.activeOperations.has(operation.key)
       ) {
         this.queue.push(operation);
         if (!isOperationBatchActive) {
@@ -197,54 +194,28 @@ export class Client {
       this.createOperationContext(opts)
     );
 
-  /** Counts up the active operation key and dispatches the operation */
-  private onOperationStart(operation: Operation) {
-    const { key } = operation;
-    this.activeOperations[key] = (this.activeOperations[key] || 0) + 1;
-    this.dispatchOperation(operation);
-  }
-
-  /** Deletes an active operation's result observable and sends a teardown signal through the exchange pipeline */
-  private onOperationEnd(operation: Operation) {
-    const { key } = operation;
-    const prevActive = this.activeOperations[key] || 0;
-    const newActive = (this.activeOperations[key] =
-      prevActive <= 0 ? 0 : prevActive - 1);
-    // Check whether this operation has now become inactive
-    if (newActive <= 0) {
-      // Delete all related queued up operations for the inactive one
-      for (let i = this.queue.length - 1; i >= 0; i--)
-        if (this.queue[i].key === operation.key) this.queue.splice(i, 1);
-      // Issue the cancellation teardown operation
-      this.dispatchOperation(
-        makeOperation('teardown', operation, operation.context)
-      );
-    }
-  }
-
   /** Executes an Operation by sending it through the exchange pipeline It returns an observable that emits all related exchange results and keeps track of this observable's subscribers. A teardown signal will be emitted when no subscribers are listening anymore. */
   executeRequestOperation<Data = any, Variables = object>(
     operation: Operation<Data, Variables>
   ): Source<OperationResult<Data, Variables>> {
-    let operationResults$ = pipe(
+    let active = this.activeOperations.get(operation.key);
+    if (active) return active;
+
+    let result$ = pipe(
       this.results$,
       filter((res: OperationResult) => res.operation.key === operation.key)
     ) as Source<OperationResult<Data, Variables>>;
-
     if (this.maskTypename) {
-      operationResults$ = pipe(
-        operationResults$,
-        map(res => {
-          res.data = maskTypename(res.data);
-          return res;
-        })
+      result$ = pipe(
+        result$,
+        map(res => ({ ...res, data: maskTypename(res.data) }))
       );
     }
 
+    // A mutation is always limited to just a single result and is never shared
     if (operation.kind === 'mutation') {
-      // A mutation is always limited to just a single result and is never shared
       return pipe(
-        operationResults$,
+        result$,
         onStart<OperationResult>(() => this.dispatchOperation(operation)),
         take(1)
       );
@@ -263,34 +234,55 @@ export class Client {
         (op: Operation) =>
           op.kind === operation.kind &&
           op.key === operation.key &&
-          op.context.requestPolicy !== 'cache-only'
-      )
+          (op.context.requestPolicy === 'network-only' ||
+            op.context.requestPolicy === 'cache-and-network')
+      ),
+      take(1)
     );
 
-    const result$ = pipe(
-      operationResults$,
+    result$ = pipe(
+      result$,
       takeUntil(teardown$),
       switchMap(result => {
         if (result.stale) return fromValue(result);
-
         return merge([
           fromValue(result),
           pipe(
             refetch$,
-            take(1),
             map(() => ({ ...result, stale: true }))
           ),
         ]);
       }),
-      onStart<OperationResult>(() => {
-        this.onOperationStart(operation);
-      }),
       onEnd<OperationResult>(() => {
-        this.onOperationEnd(operation);
+        this.activeOperations.delete(operation.key);
+        for (let i = this.queue.length - 1; i >= 0; i--)
+          if (this.queue[i].key === operation.key) this.queue.splice(i, 1);
+        this.dispatchOperation(
+          makeOperation('teardown', operation, operation.context)
+        );
       })
     );
 
-    return result$;
+    if (operation.kind === 'subscription') {
+      active = pipe(
+        result$,
+        onStart(() => {
+          this.activeOperations.set(operation.key, active!);
+          this.dispatchOperation(operation);
+        }),
+        share
+      );
+    } else {
+      active = pipe(
+        result$,
+        replayOnStart(() => {
+          this.activeOperations.set(operation.key, active!);
+          this.dispatchOperation(operation);
+        })
+      );
+    }
+
+    return active;
   }
 
   query<Data = any, Variables extends object = {}>(
@@ -318,7 +310,7 @@ export class Client {
     let result: OperationResult<Data, Variables> | null = null;
 
     pipe(
-      this.executeQuery(createRequest(query, variables), context),
+      this.query(query, variables, context),
       subscribe(res => {
         result = res;
       })
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index bba813b8..6b8469f1 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -4,7 +4,7 @@ export * from './result';
 export * from './typenames';
 export * from './stringifyVariables';
 export * from './maskTypename';
-export * from './withPromise';
+export * from './streamUtils';
 export * from './operation';
 
 export const noop = () => {
diff --git a/packages/core/src/utils/streamUtils.ts b/packages/core/src/utils/streamUtils.ts
new file mode 100644
index 00000000..1e18311e
--- /dev/null
+++ b/packages/core/src/utils/streamUtils.ts
@@ -0,0 +1,54 @@
+import {
+  Operator,
+  Source,
+  pipe,
+  toPromise,
+  take,
+  share,
+  onPush,
+  onStart,
+  onEnd,
+  subscribe,
+  make,
+} from 'wonka';
+
+import { OperationResult, PromisifiedSource } from '../types';
+
+export function withPromise<T>(source$: Source<T>): PromisifiedSource<T> {
+  (source$ as PromisifiedSource<T>).toPromise = () =>
+    pipe(source$, take(1), toPromise);
+  return source$ as PromisifiedSource<T>;
+}
+
+export function replayOnStart<T extends OperationResult>(
+  start?: () => void
+): Operator<T, T> {
+  return source$ => {
+    let replay: T | void;
+
+    const shared$ = pipe(
+      source$,
+      onPush(value => {
+        replay = value;
+      }),
+      share
+    );
+
+    return make<T>(observer => {
+      const prevReplay = replay;
+
+      const subscription = pipe(
+        shared$,
+        onEnd(observer.complete),
+        onStart(() => {
+          if (start) start();
+          if (prevReplay !== undefined && prevReplay === replay)
+            observer.next({ ...prevReplay, stale: true });
+        }),
+        subscribe(observer.next)
+      );
+
+      return subscription.unsubscribe;
+    });
+  };
+}
diff --git a/packages/core/src/utils/withPromise.ts b/packages/core/src/utils/withPromise.ts
deleted file mode 100644
index e91fccc1..00000000
--- a/packages/core/src/utils/withPromise.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Source, pipe, toPromise, take } from 'wonka';
-import { PromisifiedSource } from '../types';
-
-export function withPromise<T>(source$: Source<T>): PromisifiedSource<T> {
-  (source$ as PromisifiedSource<T>).toPromise = () =>
-    pipe(source$, take(1), toPromise);
-  return source$ as PromisifiedSource<T>;
-}
diff --git a/packages/introspection/src/minifyIntrospectionQuery.ts b/packages/introspection/src/minifyIntrospectionQuery.ts
index e915007a..0bf77bdf 100644
--- a/packages/introspection/src/minifyIntrospectionQuery.ts
+++ b/packages/introspection/src/minifyIntrospectionQuery.ts
@@ -2,37 +2,44 @@ import {
   IntrospectionQuery,
   IntrospectionType,
   IntrospectionTypeRef,
+  IntrospectionInputValue,
 } from 'graphql';
 
+let _includeScalars = false;
+let _includeEnums = false;
+let _includeInputs = false;
+let _hasAnyType = false;
+
 const anyType: IntrospectionTypeRef = {
   kind: 'SCALAR',
   name: 'Any',
 };
 
-const mapType = (
-  fromType: any,
-  toType: IntrospectionTypeRef
-): IntrospectionTypeRef => {
+const mapType = (fromType: any): IntrospectionTypeRef => {
   switch (fromType.kind) {
     case 'NON_NULL':
     case 'LIST':
       return {
         kind: fromType.kind,
-        ofType: mapType(fromType.ofType, toType),
+        ofType: mapType(fromType.ofType),
       };
 
     case 'SCALAR':
+      _hasAnyType = _hasAnyType || _includeScalars;
+      return _includeScalars ? fromType : anyType;
+
     case 'INPUT_OBJECT':
+      _hasAnyType = _hasAnyType || _includeInputs;
+      return _includeInputs ? fromType : anyType;
+
     case 'ENUM':
-      return toType;
+      _hasAnyType = _hasAnyType || _includeEnums;
+      return _includeEnums ? fromType : anyType;
 
     case 'OBJECT':
     case 'INTERFACE':
     case 'UNION':
-      return {
-        kind: fromType.kind,
-        name: fromType.name,
-      };
+      return fromType;
 
     default:
       throw new TypeError(
@@ -45,7 +52,40 @@ const minifyIntrospectionType = (
   type: IntrospectionType
 ): IntrospectionType => {
   switch (type.kind) {
-    case 'OBJECT': {
+    case 'SCALAR':
+      return {
+        kind: 'SCALAR',
+        name: type.name,
+      };
+
+    case 'ENUM':
+      return {
+        kind: 'ENUM',
+        name: type.name,
+        enumValues: type.enumValues.map(
+          value =>
+            ({
+              name: value.name,
+            } as any)
+        ),
+      };
+
+    case 'INPUT_OBJECT': {
+      return {
+        kind: 'INPUT_OBJECT',
+        name: type.name,
+        inputFields: type.inputFields.map(
+          field =>
+            ({
+              name: field.name,
+              type: mapType(field.type),
+              defaultValue: field.defaultValue || undefined,
+            } as IntrospectionInputValue)
+        ),
+      };
+    }
+
+    case 'OBJECT':
       return {
         kind: 'OBJECT',
         name: type.name,
@@ -53,12 +93,12 @@ const minifyIntrospectionType = (
           field =>
             ({
               name: field.name,
-              type: field.type && mapType(field.type, anyType),
+              type: field.type && mapType(field.type),
               args:
                 field.args &&
                 field.args.map(arg => ({
                   name: arg.name,
-                  type: mapType(arg.type, anyType),
+                  type: mapType(arg.type),
                 })),
             } as any)
         ),
@@ -69,9 +109,8 @@ const minifyIntrospectionType = (
             name: int.name,
           })),
       };
-    }
 
-    case 'INTERFACE': {
+    case 'INTERFACE':
       return {
         kind: 'INTERFACE',
         name: type.name,
@@ -79,12 +118,12 @@ const minifyIntrospectionType = (
           field =>
             ({
               name: field.name,
-              type: field.type && mapType(field.type, anyType),
+              type: field.type && mapType(field.type),
               args:
                 field.args &&
                 field.args.map(arg => ({
                   name: arg.name,
-                  type: mapType(arg.type, anyType),
+                  type: mapType(arg.type),
                 })),
             } as any)
         ),
@@ -101,9 +140,8 @@ const minifyIntrospectionType = (
             name: type.name,
           })),
       };
-    }
 
-    case 'UNION': {
+    case 'UNION':
       return {
         kind: 'UNION',
         name: type.name,
@@ -112,22 +150,39 @@ const minifyIntrospectionType = (
           name: type.name,
         })),
       };
-    }
 
     default:
       return type;
   }
 };
 
+export interface MinifySchemaOptions {
+  /** Includes scalar names (instead of an `Any` replacement) in the output when enabled. */
+  includeScalars?: boolean;
+  /** Includes enums (instead of an `Any` replacement) in the output when enabled. */
+  includeEnums?: boolean;
+  /** Includes all input objects (instead of an `Any` replacement) in the output when enabled. */
+  includeInputs?: boolean;
+  /** Includes all directives in the output when enabled. */
+  includeDirectives?: boolean;
+}
+
+/** Removes extraneous information from introspected schema data to minify it and prepare it for use on the client-side. */
 export const minifyIntrospectionQuery = (
-  schema: IntrospectionQuery
+  schema: IntrospectionQuery,
+  opts: MinifySchemaOptions = {}
 ): IntrospectionQuery => {
   if (!schema || !('__schema' in schema)) {
     throw new TypeError('Expected to receive an IntrospectionQuery.');
   }
 
+  _hasAnyType = false;
+  _includeScalars = !!opts.includeScalars;
+  _includeEnums = !!opts.includeEnums;
+  _includeInputs = !!opts.includeInputs;
+
   const {
-    __schema: { queryType, mutationType, subscriptionType, types },
+    __schema: { queryType, mutationType, subscriptionType, types, directives },
   } = schema;
 
   const minifiedTypes = types
@@ -144,6 +199,9 @@ export const minifyIntrospectionQuery = (
           return false;
         default:
           return (
+            (_includeScalars && type.kind === 'SCALAR') ||
+            (_includeEnums && type.kind === 'ENUM') ||
+            (_includeInputs && type.kind === 'INPUT_OBJECT') ||
             type.kind === 'OBJECT' ||
             type.kind === 'INTERFACE' ||
             type.kind === 'UNION'
@@ -152,7 +210,23 @@ export const minifyIntrospectionQuery = (
     })
     .map(minifyIntrospectionType);
 
-  minifiedTypes.push({ kind: 'SCALAR', name: anyType.name });
+  const minifiedDirectives = (directives || []).map(directive => ({
+    name: directive.name,
+    isRepeatable: directive.isRepeatable ? true : undefined,
+    locations: directive.locations,
+    args: directive.args.map(
+      arg =>
+        ({
+          name: arg.name,
+          type: mapType(arg.type),
+          defaultValue: arg.defaultValue || undefined,
+        } as IntrospectionInputValue)
+    ),
+  }));
+
+  if (!_includeScalars || !_includeEnums || !_includeInputs || _hasAnyType) {
+    minifiedTypes.push({ kind: 'SCALAR', name: anyType.name });
+  }
 
   return {
     __schema: {
@@ -160,7 +234,7 @@ export const minifyIntrospectionQuery = (
       mutationType,
       subscriptionType,
       types: minifiedTypes,
-      directives: [],
+      directives: opts.includeDirectives ? minifiedDirectives : [],
     },
   };
 };
diff --git a/scripts/babel/transform-function-expressions.js b/scripts/babel/transform-function-expressions.js
new file mode 100644
index 00000000..00b007fd
--- /dev/null
+++ b/scripts/babel/transform-function-expressions.js
@@ -0,0 +1,95 @@
+/** Babel plugin for cleaning up arrow function transpilation, which turns function expressions assigned to variable decalators into function declarations when it's safe to do so. */
+const functionExpressionCleanup = ({ types: t }) => {
+  /** Checks whether this block has only safe conditions up until the given node. */
+  const isSafeUntil = (block, until) => {
+    let body = [];
+    if (t.isIfStatement(block)) {
+      body = block.consequent;
+      if (block.alternate && !isSafeUntil(block.alternate, until)) {
+        return false;
+      }
+    } else if (t.isBlockStatement(block)) {
+      body = block.body;
+    }
+
+    for (let i = 0, l = body.length; i < l; i++) {
+      let node = body[i];
+      if (t.isIfStatement(node)) {
+        // An if statement is safe if it also is safe throughout
+        if (!isSafeUntil(node, until)) return false;
+      } else if (
+        !t.isVariableDeclaration(node) &&
+        !t.isFunctionDeclaration(node) &&
+        !(t.isExpressionStatement(node) && t.isAssignmentExpression(node.expression))
+      ) {
+        // only variable declarations and function declarations are safe
+        // assignments are fine too, since we're later checking the binding for "constantViolations"
+        return false;
+      } else if (node === until) {
+        return true;
+      }
+    }
+
+    return true;
+  };
+
+  return {
+    visitor: {
+      FunctionExpression(path) {
+        if (!t.isVariableDeclarator(path.parent)) {
+          // Must be on a variable declarator
+          return;
+        }
+
+        if (
+          t.isFunctionDeclaration(path.parentPath.scope.block) ||
+          t.isFunctionExpression(path.parentPath.scope.block)
+        ) {
+          // When the function expression is nested inside another function, it may be safe
+          // to turn this into a declaration, if it's only preceded by variable declarations
+          // and assignments (potentially even nested in if-statements)
+          if (!isSafeUntil(path.parentPath.scope.block.body, path.parentPath.parent))
+            return;
+        } else if (!t.isProgram(path.parentPath.scope.block)) {
+          return;
+        }
+
+        const binding = path.scope.getBinding(path.parent.id.name);
+
+        if (
+          (binding.constantViolations && binding.constantViolations.length) ||
+          binding.referencePaths.some(path =>
+            !t.isCallExpression(path.parentPath.node) &&
+            !t.isProgram(path.parentPath.node))
+        ) {
+          // The declaration must not be reassigned and it must only be referenced as plain calls
+          return;
+        }
+
+        const fn = t.functionDeclaration(
+          path.parent.id,
+          path.node.params,
+          path.node.body,
+          path.node.generator,
+          path.node.async,
+        );
+
+        // We insert after other variable declarators to not rely on hoisting and for readability
+        path.parentPath.parentPath.insertAfter(
+          // If the variabe is exported then the function declaration must also be exported.
+          t.isExportNamedDeclaration(path.parentPath.parentPath.parent)
+            ? t.exportNamedDeclaration(fn)
+            : fn
+        );
+
+        if (path.parentPath.parent.declarations.length <= 1) {
+          path.parentPath.parentPath.remove();
+        } else {
+          path.remove();
+        }
+      }
+    }
+  };
+};
+
+export default functionExpressionCleanup;
diff --git a/scripts/rollup/config.js b/scripts/rollup/config.js
index 8e9400c8..04623dcc 100644
--- a/scripts/rollup/config.js
+++ b/scripts/rollup/config.js
@@ -14,6 +14,7 @@ const input = settings.sources.reduce((acc, source) => {
       baseContents: {
         name: source.name,
         private: true,
+        version: '0.0.0',
         main: join(rel, dirname(source.main), basename(source.main, '.js')),
         module: join(rel, source.module),
         types: join(rel, source.types),
diff --git a/scripts/rollup/plugins.js b/scripts/rollup/plugins.js
index 43399ef6..410920c6 100644
--- a/scripts/rollup/plugins.js
+++ b/scripts/rollup/plugins.js
@@ -7,11 +7,11 @@ import sucrase from '@rollup/plugin-sucrase';
 import buble from '@rollup/plugin-buble';
 import replace from '@rollup/plugin-replace';
 import babel from '@rollup/plugin-babel';
-import compiler from '@ampproject/rollup-plugin-closure-compiler';
 import visualizer from 'rollup-plugin-visualizer';
 import { terser } from 'rollup-plugin-terser';
 
 import cleanup from './cleanup-plugin.js'
+import babelPluginTransformFunctionExpressions from '../babel/transform-function-expressions';
 import babelPluginTransformPipe from '../babel/transform-pipe';
 import babelPluginTransformInvariant from '../babel/transform-invariant-warning';
 import babelPluginTransformDebugTarget from '../babel/transform-debug-target';
@@ -75,7 +75,7 @@ export const makePlugins = () => [
       babelPluginTransformDebugTarget,
       babelPluginTransformPipe,
       babelPluginTransformInvariant,
-      'babel-plugin-closure-elimination',
+      babelPluginTransformFunctionExpressions,
       '@babel/plugin-transform-object-assign',
       settings.hasReact && ['@babel/plugin-transform-react-jsx', {
         pragma: 'React.createElement',
@@ -104,10 +104,6 @@ export const makeOutputPlugins = ({ isProduction, extension }) => {
     isProduction && replace({
       'process.env.NODE_ENV': JSON.stringify('production')
     }),
-    !settings.mayReexport && compiler({
-      formatting: 'PRETTY_PRINT',
-      compilation_level: 'SIMPLE_OPTIMIZATIONS'
-    }),
     cleanup({ extension }),
     isProduction ? terserMinified : terserPretty,
     isProduction && settings.isAnalyze && visualizer({
@@ -123,9 +119,6 @@ const terserPretty = terser({
   keep_fnames: true,
   ie8: false,
   compress: {
-    // We need to hoist vars for process.env.NODE_ENV if-clauses for Metro:
-    hoist_vars: true,
-    hoist_funs: true,
     pure_getters: true,
     toplevel: true,
     booleans_as_integers: false,
@@ -138,7 +131,10 @@ const terserPretty = terser({
     conditionals: false,
     join_vars: false
   },
-  mangle: false,
+  mangle: {
+    module: true,
+    keep_fnames: true,
+  },
   output: {
     beautify: true,
     braces: true,
@@ -156,6 +152,9 @@ const terserMinified = terser({
     pure_getters: true,
     passes: 10
   },
+  mangle: {
+    module: true,
+  },
   output: {
     comments: false
   }
diff --git a/yarn.lock b/yarn.lock
index 6d45436e..f5ec53ff 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,27 +2,6 @@
 # yarn lockfile v1
 
 
-"@ampproject/remapping@0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-0.2.0.tgz#07290a5c0f5eac8a4c33d38aa0d15a3416db432e"
-  integrity sha512-a4EztS9/GOVQjX5Ol+Iz33TFhaXvYBF7aB6D8+Qz0/SCIxOm3UNRhGZiwcCuJ8/Ifc6NCogp3S48kc5hFxRpUw==
-  dependencies:
-    "@jridgewell/resolve-uri" "1.0.0"
-    sourcemap-codec "1.4.8"
-
-"@ampproject/rollup-plugin-closure-compiler@^0.26.0":
-  version "0.26.0"
-  resolved "https://registry.yarnpkg.com/@ampproject/rollup-plugin-closure-compiler/-/rollup-plugin-closure-compiler-0.26.0.tgz#69f8265e5fdbf3e26905eaaedc60cb5982bd6be0"
-  integrity sha512-wuHzGE6BDhDR0L7nUPlpQDPGiGnMw+b0B+cDPG0S5TatOmFNQva8KSNdBHan3L9RbvNyYXOXicuCrZtSoBfrBg==
-  dependencies:
-    "@ampproject/remapping" "0.2.0"
-    acorn "7.2.0"
-    acorn-walk "7.1.1"
-    estree-walker "2.0.1"
-    google-closure-compiler "20200517.0.0"
-    magic-string "0.25.7"
-    uuid "8.1.0"
-
 "@babel/cli@^7.5.5":
   version "7.12.13"
   resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.12.13.tgz#ae2c6a75fa43f3db4bca0659799b0dfca3f5212b"
@@ -1602,11 +1581,6 @@
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
 
-"@jridgewell/resolve-uri@1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-1.0.0.tgz#3fdf5798f0b49e90155896f6291df186eac06c83"
-  integrity sha512-9oLAnygRMi8Q5QkYEU4XWK04B+nuoXoxjRvRxgjuChkLZFBja0YPSgdZ7dZtwhncLBcQe/I/E+fLuk5qxcYVJA==
-
 "@manypkg/find-root@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@manypkg/find-root/-/find-root-1.1.0.tgz#a62d8ed1cd7e7d4c11d9d52a8397460b5d4ad29f"
@@ -2975,21 +2949,11 @@ acorn-jsx@^5.2.0, acorn-jsx@^5.3.1:
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b"
   integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
 
-acorn-walk@7.1.1:
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e"
-  integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==
-
 acorn-walk@^7.1.1:
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
   integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
 
-acorn@7.2.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe"
-  integrity sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ==
-
 acorn@^6.4.1:
   version "6.4.2"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
@@ -3575,11 +3539,6 @@ babel-plugin-apply-mdx-type-prop@1.6.22:
     "@babel/helper-plugin-utils" "7.10.4"
     "@mdx-js/util" "1.6.22"
 
-babel-plugin-closure-elimination@^1.3.2:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-closure-elimination/-/babel-plugin-closure-elimination-1.3.2.tgz#2c9a90360bdf888fd3b3694391a745a70ce18c34"
-  integrity sha512-GJnezbVp5ejiwh74fXJPznsrrWHR9bTuJV20FhXivbgEtg1WyNG/9KaDyHEpfU7G9iB6Gy+F2UffYLZ7DJh+Jw==
-
 babel-plugin-dynamic-import-node@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
@@ -4558,7 +4517,7 @@ chalk@2.4.1:
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@2.4.2, chalk@2.x, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -4860,11 +4819,6 @@ cliui@^7.0.2:
     strip-ansi "^6.0.0"
     wrap-ansi "^7.0.0"
 
-clone-buffer@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
-  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
-
 clone-response@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
@@ -4872,30 +4826,11 @@ clone-response@1.0.2:
   dependencies:
     mimic-response "^1.0.0"
 
-clone-stats@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
-  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
-
 clone@^1.0.2:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
   integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
 
-clone@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
-
-cloneable-readable@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
-  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
-  dependencies:
-    inherits "^2.0.1"
-    process-nextick-args "^2.0.0"
-    readable-stream "^2.3.5"
-
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -6792,11 +6727,6 @@ estraverse@^5.1.0, estraverse@^5.2.0:
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
   integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
-estree-walker@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0"
-  integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg==
-
 estree-walker@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
@@ -7901,47 +7831,6 @@ good-listener@^1.2.2:
   dependencies:
     delegate "^3.1.2"
 
-google-closure-compiler-java@^20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler-java/-/google-closure-compiler-java-20200517.0.0.tgz#778370c22273c9085f4cf959ce063f8f112c02ac"
-  integrity sha512-JVZBiyyXwcYi6Yc3lO6dF2hMLJA4OzPm4/mgsem/tF1vk2HsWTnL3GTaBsPB2ENVZp0hoqsd4KgpPiG9ssNWxw==
-
-google-closure-compiler-js@^20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20200517.0.0.tgz#9cb0861f764073d1c4d3b7453b74073ccb1ecfb1"
-  integrity sha512-dz6dOUHx5nhdIqMRXacAYS8aJfLvw4IKxGg28Hq/zeeDPHlX3P3iBK20NgFDfT8zdushThymtMqChSy7C5eyfA==
-
-google-closure-compiler-linux@^20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler-linux/-/google-closure-compiler-linux-20200517.0.0.tgz#2b9ecb634130060174aff5c52329a694ea4be68b"
-  integrity sha512-S5xPh6TtP+ESzZrmQLcDDqtZAsCVTbdI4VS98wQlN6IMZTd94nAnOCg9mrxQNAgop2t4sdsv/KuH0BGPUWEZ+w==
-
-google-closure-compiler-osx@^20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler-osx/-/google-closure-compiler-osx-20200517.0.0.tgz#9394e9a2fd97e3729fc3bd2abcffff6aab2cfcaa"
-  integrity sha512-FWIcsKqLllLjdOBZd7azijVaObydgRd0obVNi63eUfC5MX6T4qxKumGCyor2UCNY6by2ESz+PlGqCFzFhZ6b2g==
-
-google-closure-compiler-windows@^20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler-windows/-/google-closure-compiler-windows-20200517.0.0.tgz#c5cdde438c29458666a83358567b12072924ed6c"
-  integrity sha512-UXhjRGwS8deTkRla/riyVq3psscgMuw78lepEPtq5NgbumgJzY2+IQP9q+4MVOfJW58Rv0JUWKAFOnBBSZWcAQ==
-
-google-closure-compiler@20200517.0.0:
-  version "20200517.0.0"
-  resolved "https://registry.yarnpkg.com/google-closure-compiler/-/google-closure-compiler-20200517.0.0.tgz#6c47f99fc1be59bd4f9e23c5a8f2e66d64b54143"
-  integrity sha512-80W9zBS9Ajk1T5InWCfsoPohDmo5T1AAyw1rHh5+dgb/jPgwC65KhY+oJozTncf+/7tyQHJXozTARwhSlBUcMg==
-  dependencies:
-    chalk "2.x"
-    google-closure-compiler-java "^20200517.0.0"
-    google-closure-compiler-js "^20200517.0.0"
-    minimist "1.x"
-    vinyl "2.x"
-    vinyl-sourcemaps-apply "^0.2.0"
-  optionalDependencies:
-    google-closure-compiler-linux "^20200517.0.0"
-    google-closure-compiler-osx "^20200517.0.0"
-    google-closure-compiler-windows "^20200517.0.0"
-
 got@^8.3.1:
   version "8.3.2"
   resolved "https://registry.yarnpkg.com/got/-/got-8.3.2.tgz#1d23f64390e97f776cac52e5b936e5f514d2e937"
@@ -10307,7 +10196,7 @@ lz-string@^1.4.4:
   resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
   integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
 
-magic-string@0.25.7, magic-string@^0.25.0, magic-string@^0.25.7:
+magic-string@^0.25.0, magic-string@^0.25.7:
   version "0.25.7"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
   integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==
@@ -10712,7 +10601,7 @@ minimist@1.2.3:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.3.tgz#3db5c0765545ab8637be71f333a104a965a9ca3f"
   integrity sha512-+bMdgqjMN/Z77a6NlY/I3U5LlRDbnmaAk6lDveAPKwSpcPM4tKAuYsvYF8xjhOPXhOYGe/73vVLVez5PW+jqhw==
 
-minimist@1.x, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
+minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
@@ -12361,7 +12250,7 @@ prismjs@^1.21.0, prismjs@~1.23.0:
   optionalDependencies:
     clipboard "^2.0.0"
 
-process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
+process-nextick-args@~2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
   integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
@@ -13514,11 +13403,6 @@ repeat-string@^1.5.4, repeat-string@^1.6.1:
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
-replace-ext@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
-  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
-
 request-promise-core@1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f"
@@ -14354,7 +14238,7 @@ source-map-url@^0.4.0:
   resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
   integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
 
-source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.6, source-map@^0.5.7:
+source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
@@ -14369,7 +14253,7 @@ source-map@^0.7.3, source-map@~0.7.2:
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
   integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
 
-sourcemap-codec@1.4.8, sourcemap-codec@^1.4.4:
+sourcemap-codec@^1.4.4:
   version "1.4.8"
   resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
   integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
@@ -15923,11 +15807,6 @@ utils-merge@1.0.1:
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
-uuid@8.1.0:
-  version "8.1.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d"
-  integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==
-
 uuid@^3.3.2, uuid@^3.4.0:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
@@ -16012,25 +15891,6 @@ vfile@^4.0.0:
     unist-util-stringify-position "^2.0.0"
     vfile-message "^2.0.0"
 
-vinyl-sourcemaps-apply@^0.2.0:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705"
-  integrity sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU=
-  dependencies:
-    source-map "^0.5.1"
-
-vinyl@2.x:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974"
-  integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==
-  dependencies:
-    clone "^2.1.1"
-    clone-buffer "^1.0.0"
-    clone-stats "^1.0.0"
-    cloneable-readable "^1.0.0"
-    remove-trailing-separator "^1.0.1"
-    replace-ext "^1.0.0"
-
 vm-browserify@^1.0.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"


Test patch:
diff --git a/packages/core/src/__snapshots__/client.test.ts.snap b/packages/core/src/__snapshots__/client.test.ts.snap
index 88745fc4..d49c1ab2 100644
--- a/packages/core/src/__snapshots__/client.test.ts.snap
+++ b/packages/core/src/__snapshots__/client.test.ts.snap
@@ -2,7 +2,7 @@
 
 exports[`createClient passes snapshot 1`] = `
 Client {
-  "activeOperations": Object {},
+  "activeOperations": Map {},
   "createOperationContext": [Function],
   "createRequestOperation": [Function],
   "dispatchOperation": [Function],
diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts
index 71044635..7bf8f20a 100755
--- a/packages/core/src/client.test.ts
+++ b/packages/core/src/client.test.ts
@@ -11,12 +11,14 @@ import {
   filter,
   toArray,
   tap,
+  take,
 } from 'wonka';
+
 import { gql } from './gql';
 import { Exchange, Operation, OperationResult } from './types';
 import { makeOperation } from './utils';
 import { createClient } from './client';
-import { queryOperation } from './test-utils';
+import { queryOperation, subscriptionOperation } from './test-utils';
 
 const url = 'https://hostname.com';
 
@@ -507,3 +509,223 @@ describe('queuing behavior', () => {
     unsubscribe();
   });
 });
+
+describe('shared sources behavior', () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('replays results from prior operation result as needed', 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 resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+
+    expect(resultOne).toHaveBeenCalledTimes(0);
+
+    jest.advanceTimersByTime(1);
+
+    expect(resultOne).toHaveBeenCalledTimes(1);
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 1,
+      operation: queryOperation,
+    });
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 1,
+      stale: true,
+      operation: queryOperation,
+    });
+
+    jest.advanceTimersByTime(1);
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 2,
+      operation: queryOperation,
+    });
+  });
+
+  it('replayed results are not emitted on the shared source', () => {
+    const exchange: Exchange = () => ops$ => {
+      let i = 0;
+      return pipe(
+        ops$,
+        map(op => ({
+          data: ++i,
+          operation: op,
+        })),
+        take(1)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+
+    expect(resultOne).toHaveBeenCalledTimes(1);
+    expect(resultTwo).toHaveBeenCalledTimes(1);
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 1,
+      operation: queryOperation,
+      stale: true,
+    });
+  });
+
+  it('does nothing when no operation result has been emitted yet', () => {
+    const exchange: Exchange = () => ops$ => {
+      return pipe(
+        ops$,
+        map(op => ({ data: 1, operation: op })),
+        filter(() => false)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+
+    expect(resultOne).toHaveBeenCalledTimes(0);
+    expect(resultTwo).toHaveBeenCalledTimes(0);
+  });
+
+  it('skips replaying results when a result is emitted immediately', () => {
+    const exchange: Exchange = () => ops$ => {
+      let i = 0;
+      return pipe(
+        ops$,
+        map(op => ({ data: ++i, operation: op }))
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 1,
+      operation: queryOperation,
+    });
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 2,
+      operation: queryOperation,
+    });
+
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 2,
+      operation: queryOperation,
+    });
+  });
+
+  it('replays stale results as needed', () => {
+    const exchange: Exchange = () => ops$ => {
+      return pipe(
+        ops$,
+        map(op => ({ stale: true, data: 1, operation: op })),
+        take(1)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultOne));
+
+    expect(resultOne).toHaveBeenCalledWith({
+      data: 1,
+      operation: queryOperation,
+      stale: true,
+    });
+
+    pipe(client.executeRequestOperation(queryOperation), subscribe(resultTwo));
+
+    expect(resultTwo).toHaveBeenCalledWith({
+      data: 1,
+      operation: queryOperation,
+      stale: true,
+    });
+  });
+
+  it('does nothing when operation is a subscription has been emitted yet', () => {
+    const exchange: Exchange = () => ops$ => {
+      return pipe(
+        ops$,
+        map(op => ({ data: 1, operation: op })),
+        take(1)
+      );
+    };
+
+    const client = createClient({
+      url: 'test',
+      exchanges: [exchange],
+    });
+
+    const resultOne = jest.fn();
+    const resultTwo = jest.fn();
+
+    pipe(
+      client.executeRequestOperation(subscriptionOperation),
+      subscribe(resultOne)
+    );
+    expect(resultOne).toHaveBeenCalledTimes(1);
+
+    pipe(
+      client.executeRequestOperation(subscriptionOperation),
+      subscribe(resultTwo)
+    );
+    expect(resultTwo).toHaveBeenCalledTimes(0);
+  });
+});