Repository: FormidableLabs/urql
Pull request #750 (2020-04-24 15:14:34)

(graphcache) - Implement RFC for optimistic behavior enhancements
## Summary

This PR implements both recommendations from RFC #747.

These changes have been validated and tested so far against @frederikhors' unexpected behaviour reproductions in CodeSandboxes:

- https://codesandbox.io/s/urql-svelte-cacheinvalidate-many-items-j89ru
- https://codesandbox.io/s/urql-svelte-add-mutation-back-to-list-t98y7

## Set of changes

- Convert dependencies data structure from `Set<string>` to `type Dependencies = Record<string, true>`
- Keep track of optimistically updated dependencies (of type `Dependencies`)
- Block refetching of operations that have active optimistic updates and keep track of them (see above)
- Allow `executePendingOperations` to trigger refetch for previously blocked `cache-and-network` operations
- Keep track of optimistic mutation that are pending at the same time, and flush their results simultaneously, also erasing the optimistically updated (i.e. blocked) dependencies at the same time

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 22b4bef02e69575d2d0173809f228120b86fbf89

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, 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/optimistic-mutation-enhancements?filename=.changeset/silly-beers-applaud.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0A(graphcache)%20-%20Implement%20optimistic%20refetch%20blocking%0A)


kitten: Both new strategies seem to be working. It's pretty exciting how this makes both aspects of optimistic mutations (refetching while they're active, and settling with/without optimistic updates) more intuitive.

This branch only needs some new unit tests.

Patch:
diff --git a/.changeset/little-chicken-ring.md b/.changeset/little-chicken-ring.md
new file mode 100644
index 00000000..bf8dbe91
--- /dev/null
+++ b/.changeset/little-chicken-ring.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': minor
+---
+
+Implement refetch blocking for queries that are affected by optimistic update. When a query would normally be refetched, either because it was partial or a cache-and-network operation, we now wait if it touched optimistic data for that optimistic mutation to complete. This prevents optimistic update data from unexpectedly disappearing
diff --git a/.changeset/seven-onions-check.md b/.changeset/seven-onions-check.md
new file mode 100644
index 00000000..a8ea2f01
--- /dev/null
+++ b/.changeset/seven-onions-check.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': minor
+---
+
+Implement optimistic mutation result flushing. Mutation results for mutation that have had optimistic updates will now wait for all optimistic mutations to complete at the same time before being applied to the cache. This sometimes does delay cache updates to until after multiple mutations have completed, but it does prevent optimistic data from being accidentally committed permanently, which is more intuitive.
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index cc1bb5ef..ff2b136f 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -26,10 +26,9 @@ import {
 } from 'wonka';
 
 import { query, write, writeOptimistic } from './operations';
-import { hydrateData } from './store/data';
-import { makeDict } from './helpers/dict';
+import { makeDict, isDictEmpty } from './helpers/dict';
 import { filterVariables, getMainOperation } from './ast';
-import { Store, noopDataState, reserveLayer } from './store';
+import { Store, noopDataState, hydrateData, reserveLayer } from './store';
 
 import {
   UpdatesConfig,
@@ -37,17 +36,18 @@ import {
   OptimisticMutationConfig,
   KeyingConfig,
   StorageAdapter,
+  Dependencies,
 } from './types';
 
 type OperationResultWithMeta = OperationResult & {
   outcome: CacheOutcome;
+  dependencies: Dependencies;
 };
 
+type Operations = Set<number>;
 type OperationMap = Map<number, Operation>;
-
-interface DependentOperations {
-  [key: string]: number[];
-}
+type OptimisticDependencies = Map<number, Dependencies>;
+type DependentOperations = Record<string, number[]>;
 
 // Returns the given operation result with added cacheOutcome meta field
 const addCacheOutcome = (op: Operation, outcome: CacheOutcome): Operation => ({
@@ -96,17 +96,25 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     });
   }
 
-  const optimisticKeysToDependencies = new Map<number, Set<string>>();
+  const optimisticKeysToDependencies: OptimisticDependencies = new Map();
+  const mutationResultBuffer: OperationResult[] = [];
   const ops: OperationMap = new Map();
+  const blockedDependencies: Dependencies = makeDict();
+  const requestedRefetch: Operations = new Set();
   const deps: DependentOperations = makeDict();
 
+  const isBlockedByOptimisticUpdate = (dependencies: Dependencies): boolean => {
+    for (const dep in dependencies) if (blockedDependencies[dep]) return true;
+    return false;
+  };
+
   const collectPendingOperations = (
-    pendingOperations: Set<number>,
-    dependencies: void | Set<string>
+    pendingOperations: Operations,
+    dependencies: void | Dependencies
   ) => {
     if (dependencies) {
       // Collect operations that will be updated due to cache changes
-      dependencies.forEach(dep => {
+      for (const dep in dependencies) {
         const keys = deps[dep];
         if (keys) {
           deps[dep] = [];
@@ -114,13 +122,13 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
             pendingOperations.add(keys[i]);
           }
         }
-      });
+      }
     }
   };
 
   const executePendingOperations = (
     operation: Operation,
-    pendingOperations: Set<number>
+    pendingOperations: Operations
   ) => {
     // Reexecute collected operations and delete them from the mapping
     pendingOperations.forEach(key => {
@@ -128,7 +136,12 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
         const op = ops.get(key);
         if (op) {
           ops.delete(key);
-          client.reexecuteOperation(toRequestPolicy(op, 'cache-first'));
+          let policy: RequestPolicy = 'cache-first';
+          if (requestedRefetch.has(key)) {
+            requestedRefetch.delete(key);
+            policy = 'cache-and-network';
+          }
+          client.reexecuteOperation(toRequestPolicy(op, policy));
         }
       }
     });
@@ -137,8 +150,12 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
   // This registers queries with the data layer to ensure commutativity
   const prepareForwardedOperation = (operation: Operation) => {
     if (operation.operationName === 'query') {
+      // Pre-reserve the position of the result layer
       reserveLayer(store.data, operation.key);
     } else if (operation.operationName === 'teardown') {
+      // Delete reference to operation if any exists to release it
+      ops.delete(operation.key);
+      // Mark operation layer as done
       noopDataState(store.data, operation.key);
     } else if (
       operation.operationName === 'mutation' &&
@@ -146,9 +163,17 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     ) {
       // This executes an optimistic update for mutations and registers it if necessary
       const { dependencies } = writeOptimistic(store, operation, operation.key);
-      if (dependencies.size !== 0) {
+      if (!isDictEmpty(dependencies)) {
+        // Update blocked optimistic dependencies
+        for (const dep in dependencies) {
+          blockedDependencies[dep] = true;
+        }
+
+        // Store optimistic dependencies for update
         optimisticKeysToDependencies.set(operation.key, dependencies);
-        const pendingOperations = new Set<number>();
+
+        // Update related queries
+        const pendingOperations: Operations = new Set();
         collectPendingOperations(pendingOperations, dependencies);
         executePendingOperations(operation, pendingOperations);
       }
@@ -167,11 +192,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
   };
 
   // This updates the known dependencies for the passed operation
-  const updateDependencies = (op: Operation, dependencies: Set<string>) => {
-    dependencies.forEach(dep => {
+  const updateDependencies = (op: Operation, dependencies: Dependencies) => {
+    for (const dep in dependencies) {
       (deps[dep] || (deps[dep] = [])).push(op.key);
       ops.set(op.key, op);
-    });
+    }
   };
 
   // Retrieves a query result from cache and adds an `isComplete` hint
@@ -186,37 +211,34 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
         : 'partial'
       : 'miss';
 
-    if (res.data) {
-      updateDependencies(operation, res.dependencies);
-    }
+    updateDependencies(operation, res.dependencies);
 
     return {
       outcome: cacheOutcome,
       operation,
       data: res.data,
+      dependencies: res.dependencies,
     };
   };
 
   // Take any OperationResult and update the cache with it
-  const updateCacheWithResult = (result: OperationResult): OperationResult => {
+  const updateCacheWithResult = (
+    result: OperationResult,
+    pendingOperations: Operations
+  ): OperationResult => {
     const { operation, error, extensions } = result;
-
-    // Clear old optimistic values from the store
     const { key } = operation;
-    const pendingOperations = new Set<number>();
 
     if (operation.operationName === 'mutation') {
       // Collect previous dependencies that have been written for optimistic updates
-      collectPendingOperations(
-        pendingOperations,
-        optimisticKeysToDependencies.get(key)
-      );
+      const dependencies = optimisticKeysToDependencies.get(key);
+      collectPendingOperations(pendingOperations, dependencies);
       optimisticKeysToDependencies.delete(key);
     } else {
       reserveLayer(store.data, operation.key);
     }
 
-    let queryDependencies: Set<string> | void;
+    let queryDependencies: void | Dependencies;
     if (result.data) {
       // Write the result to cache and collect all dependencies that need to be
       // updated
@@ -235,8 +257,6 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       noopDataState(store.data, operation.key);
     }
 
-    // Execute all pending operations related to changed dependencies
-    executePendingOperations(result.operation, pendingOperations);
     // Update this operation's dependencies if it's a query
     if (queryDependencies) {
       updateDependencies(result.operation, queryDependencies);
@@ -290,7 +310,8 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       filter(res => {
         return (
           res.outcome === 'miss' &&
-          res.operation.context.requestPolicy !== 'cache-only'
+          res.operation.context.requestPolicy !== 'cache-only' &&
+          !isBlockedByOptimisticUpdate(res.dependencies)
         );
       }),
       map(res => {
@@ -314,7 +335,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       ),
       map(
         (res: OperationResultWithMeta): OperationResult => {
-          const { operation, outcome } = res;
+          const { operation, outcome, dependencies } = res;
           const result: OperationResult = {
             operation: addCacheOutcome(operation, outcome),
             data: res.data,
@@ -328,9 +349,15 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
               outcome === 'partial')
           ) {
             result.stale = true;
-            client.reexecuteOperation(
-              toRequestPolicy(operation, 'network-only')
-            );
+            if (!isBlockedByOptimisticUpdate(dependencies)) {
+              client.reexecuteOperation(
+                toRequestPolicy(operation, 'network-only')
+              );
+            } else if (
+              operation.context.requestPolicy === 'cache-and-network'
+            ) {
+              requestedRefetch.add(operation.key);
+            }
           }
 
           dispatchDebug({
@@ -353,9 +380,64 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       merge([nonCacheOps$, cacheMissOps$]),
       map(prepareForwardedOperation),
       forward,
-      map(updateCacheWithResult)
+      share
+    );
+
+    // Results that can immediately be resolved
+    const nonOptimisticResults$ = pipe(
+      result$,
+      filter(result => !optimisticKeysToDependencies.has(result.operation.key)),
+      map(result => {
+        const pendingOperations: Operations = new Set();
+        // Update the cache with the incoming API result
+        const cacheResult = updateCacheWithResult(result, pendingOperations);
+        // Execute all dependent queries
+        executePendingOperations(result.operation, pendingOperations);
+        return cacheResult;
+      })
+    );
+
+    // Prevent mutations that were previously optimistic from being flushed
+    // immediately and instead clear them out slowly
+    const optimisticMutationCompletion$ = pipe(
+      result$,
+      filter(result => optimisticKeysToDependencies.has(result.operation.key)),
+      mergeMap(
+        (result: OperationResult): Source<OperationResult> => {
+          const length = mutationResultBuffer.push(result);
+          if (length < optimisticKeysToDependencies.size) {
+            return empty;
+          }
+
+          for (let i = 0; i < mutationResultBuffer.length; i++) {
+            reserveLayer(store.data, mutationResultBuffer[i].operation.key);
+          }
+
+          for (const dep in blockedDependencies) {
+            delete blockedDependencies[dep];
+          }
+
+          const results: OperationResult[] = [];
+          const pendingOperations: Operations = new Set();
+
+          let bufferedResult: OperationResult | void;
+          while ((bufferedResult = mutationResultBuffer.shift()))
+            results.push(
+              updateCacheWithResult(bufferedResult, pendingOperations)
+            );
+
+          // Execute all dependent queries as a single batch
+          executePendingOperations(result.operation, pendingOperations);
+
+          return fromArray(results);
+        }
+      )
     );
 
-    return merge([result$, cacheResult$]);
+    return merge([
+      nonOptimisticResults$,
+      optimisticMutationCompletion$,
+      cacheResult$,
+    ]);
   };
 };
diff --git a/exchanges/graphcache/src/helpers/dict.ts b/exchanges/graphcache/src/helpers/dict.ts
index b9b5234b..ac3cd501 100644
--- a/exchanges/graphcache/src/helpers/dict.ts
+++ b/exchanges/graphcache/src/helpers/dict.ts
@@ -1,1 +1,6 @@
 export const makeDict = (): any => Object.create(null);
+
+export const isDictEmpty = (x: any) => {
+  for (const _ in x) return false;
+  return true;
+};
diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts
index 2e6ccfe3..b7ad102d 100644
--- a/exchanges/graphcache/src/operations/invalidate.ts
+++ b/exchanges/graphcache/src/operations/invalidate.ts
@@ -31,7 +31,7 @@ export const invalidate = (store: Store, request: OperationRequest) => {
   read(store, request);
   InMemoryData.unforkDependencies();
 
-  dependencies.forEach(dependency => {
+  for (const dependency in dependencies) {
     if (dependency.startsWith(`${store.data.queryRootKey}.`)) {
       const fieldKey = dependency.slice(`${store.data.queryRootKey}.`.length);
       InMemoryData.writeLink(store.data.queryRootKey, fieldKey);
@@ -39,7 +39,7 @@ export const invalidate = (store: Store, request: OperationRequest) => {
     } else {
       invalidateEntity(dependency);
     }
-  });
+  }
 
   InMemoryData.gc();
 };
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index cf90d4f8..63dd75e4 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -22,6 +22,7 @@ import {
   Link,
   OperationRequest,
   NullArray,
+  Dependencies,
 } from '../types';
 
 import {
@@ -51,7 +52,7 @@ import {
 } from '../ast';
 
 export interface QueryResult {
-  dependencies: Set<string>;
+  dependencies: Dependencies;
   partial: boolean;
   data: null | Data;
 }
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 912647fc..55bdac2c 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -15,7 +15,14 @@ import {
 
 import { invariant, warn, pushDebugNode } from '../helpers/help';
 
-import { NullArray, Variables, Data, Link, OperationRequest } from '../types';
+import {
+  NullArray,
+  Variables,
+  Data,
+  Link,
+  OperationRequest,
+  Dependencies,
+} from '../types';
 
 import {
   Store,
@@ -37,7 +44,7 @@ import {
 
 export interface WriteResult {
   data: null | Data;
-  dependencies: Set<string>;
+  dependencies: Dependencies;
 }
 
 /** Writes a request given its response to the store */
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index b4c938c1..878a6520 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -6,6 +6,7 @@ import {
   FieldInfo,
   StorageAdapter,
   SerializedEntries,
+  Dependencies,
 } from '../types';
 
 import {
@@ -54,8 +55,8 @@ export interface InMemoryData {
 }
 
 let currentData: null | InMemoryData = null;
-let currentDependencies: null | Set<string> = null;
-let previousDependencies: null | Set<string> = null;
+let currentDependencies: null | Dependencies = null;
+let previousDependencies: null | Dependencies = null;
 let currentOptimisticKey: null | number = null;
 let currentIgnoreOptimistic = false;
 
@@ -72,7 +73,7 @@ export const initDataState = (
 ) => {
   currentData = data;
   previousDependencies = currentDependencies;
-  currentDependencies = new Set();
+  currentDependencies = makeDict();
   currentIgnoreOptimistic = false;
   if (process.env.NODE_ENV !== 'production') {
     currentDebugStack.length = 0;
@@ -160,7 +161,7 @@ export const noopDataState = (
 };
 
 /** As we're writing, we keep around all the records and links we've read or have written to */
-export const getCurrentDependencies = (): Set<string> => {
+export const getCurrentDependencies = (): Dependencies => {
   invariant(
     currentDependencies !== null,
     'Invalid Cache call: The cache may only be accessed or mutated during' +
@@ -172,9 +173,9 @@ export const getCurrentDependencies = (): Set<string> => {
   return currentDependencies;
 };
 
-export const forkDependencies = (): Set<string> => {
+export const forkDependencies = (): Dependencies => {
   previousDependencies = currentDependencies;
-  return (currentDependencies = new Set());
+  return (currentDependencies = makeDict());
 };
 
 export const unforkDependencies = () => {
@@ -369,9 +370,9 @@ export const gc = () => {
 const updateDependencies = (entityKey: string, fieldKey?: string) => {
   if (fieldKey !== '__typename') {
     if (entityKey !== currentData!.queryRootKey) {
-      currentDependencies!.add(entityKey);
+      currentDependencies![entityKey] = true;
     } else if (fieldKey !== undefined) {
-      currentDependencies!.add(joinKeys(entityKey, fieldKey));
+      currentDependencies![joinKeys(entityKey, fieldKey)] = true;
     }
   }
 };
@@ -489,7 +490,7 @@ const createLayer = (data: InMemoryData, layerKey: number) => {
 };
 
 /** Clears all links and records of an optimistic layer */
-export const clearLayer = (data: InMemoryData, layerKey: number) => {
+const clearLayer = (data: InMemoryData, layerKey: number) => {
   if (data.refLock[layerKey]) {
     delete data.refLock[layerKey];
     delete data.records.optimistic[layerKey];
diff --git a/exchanges/graphcache/src/store/index.ts b/exchanges/graphcache/src/store/index.ts
index f7b4b6f5..b88a0625 100644
--- a/exchanges/graphcache/src/store/index.ts
+++ b/exchanges/graphcache/src/store/index.ts
@@ -3,8 +3,8 @@ export {
   clearDataState,
   noopDataState,
   reserveLayer,
-  clearLayer,
   getCurrentDependencies,
+  hydrateData,
 } from './data';
 
 export * from './keys';
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index b16fea07..05f59889 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -181,3 +181,5 @@ export interface StorageAdapter {
   read(): Promise<SerializedEntries>;
   write(data: SerializedEntries): Promise<void>;
 }
+
+export type Dependencies = Record<string, true>;
diff --git a/package.json b/package.json
index 91a210b1..eb167431 100644
--- a/package.json
+++ b/package.json
@@ -71,7 +71,7 @@
     "glob": "^7.1.6",
     "graphql": "^15.0.0",
     "graphql-tag": "^2.10.1",
-    "husky": "^4.2.3",
+    "husky": "^4.2.5",
     "invariant": "^2.2.4",
     "jest": "^25.3.0",
     "jest-watch-yarn-workspaces": "^1.1.0",
diff --git a/yarn.lock b/yarn.lock
index 5a27d505..c47b3c1e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3743,6 +3743,14 @@ chalk@^3.0.0:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
+chalk@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72"
+  integrity sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
 character-entities-html4@^1.0.0:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125"
@@ -4110,7 +4118,7 @@ commondir@^1.0.1:
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
   integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
 
-compare-versions@^3.5.1:
+compare-versions@^3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
   integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
@@ -7192,14 +7200,14 @@ human-signals@^1.1.1:
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
   integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
 
-husky@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.3.tgz#3b18d2ee5febe99e27f2983500202daffbc3151e"
-  integrity sha512-VxTsSTRwYveKXN4SaH1/FefRJYCtx+wx04sSVcOpD7N2zjoHxa+cEJ07Qg5NmV3HAK+IRKOyNVpi2YBIVccIfQ==
+husky@^4.2.5:
+  version "4.2.5"
+  resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36"
+  integrity sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ==
   dependencies:
-    chalk "^3.0.0"
+    chalk "^4.0.0"
     ci-info "^2.0.0"
-    compare-versions "^3.5.1"
+    compare-versions "^3.6.0"
     cosmiconfig "^6.0.0"
     find-versions "^3.2.0"
     opencollective-postinstall "^2.0.2"


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index ae3b14d1..793591aa 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -696,6 +696,204 @@ describe('optimistic updates', () => {
     expect(result).toHaveBeenCalledTimes(4);
   });
 
+  it('batches optimistic mutation result application', () => {
+    jest.useFakeTimers();
+
+    const mutation = gql`
+      mutation {
+        concealAuthor {
+          id
+          name
+        }
+      }
+    `;
+
+    const optimisticMutationData = {
+      __typename: 'Mutation',
+      concealAuthor: {
+        __typename: 'Author',
+        id: '123',
+        name: '[REDACTED OFFLINE]',
+      },
+    };
+
+    const mutationData = {
+      __typename: 'Mutation',
+      concealAuthor: {
+        __typename: 'Author',
+        id: '123',
+        name: '[REDACTED ONLINE]',
+      },
+    };
+
+    const client = createClient({ url: 'http://0.0.0.0' });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const reexec = jest
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const opOne = client.createRequestOperation('query', {
+      key: 1,
+      query: queryOne,
+    });
+
+    const opMutationOne = client.createRequestOperation('mutation', {
+      key: 2,
+      query: mutation,
+    });
+
+    const opMutationTwo = client.createRequestOperation('mutation', {
+      key: 3,
+      query: mutation,
+    });
+
+    const response = jest.fn(
+      (forwardOp: Operation): OperationResult => {
+        if (forwardOp.key === 1) {
+          return { operation: opOne, data: queryOneData };
+        } else if (forwardOp.key === 2) {
+          return { operation: opMutationOne, data: mutationData };
+        } else if (forwardOp.key === 3) {
+          return { operation: opMutationTwo, data: mutationData };
+        }
+
+        return undefined as any;
+      }
+    );
+
+    const result = jest.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, delay(3), map(response));
+
+    const optimistic = {
+      concealAuthor: jest.fn(() => optimisticMutationData.concealAuthor) as any,
+    };
+
+    pipe(
+      cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$),
+      filter(x => x.operation.operationName === 'mutation'),
+      tap(result),
+      publish
+    );
+
+    next(opOne);
+    jest.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(0);
+
+    next(opMutationOne);
+    jest.advanceTimersByTime(1);
+    next(opMutationTwo);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(optimistic.concealAuthor).toHaveBeenCalledTimes(2);
+    expect(reexec).toHaveBeenCalledTimes(2);
+    expect(result).toHaveBeenCalledTimes(0);
+
+    jest.advanceTimersByTime(2);
+    expect(response).toHaveBeenCalledTimes(2);
+    expect(result).toHaveBeenCalledTimes(0);
+
+    jest.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(3);
+    expect(result).toHaveBeenCalledTimes(2);
+  });
+
+  it('blocks refetches of overlapping queries', () => {
+    jest.useFakeTimers();
+
+    const mutation = gql`
+      mutation {
+        concealAuthor {
+          id
+          name
+        }
+      }
+    `;
+
+    const optimisticMutationData = {
+      __typename: 'Mutation',
+      concealAuthor: {
+        __typename: 'Author',
+        id: '123',
+        name: '[REDACTED OFFLINE]',
+      },
+    };
+
+    const client = createClient({ url: 'http://0.0.0.0' });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const reexec = jest
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const opOne = client.createRequestOperation(
+      'query',
+      {
+        key: 1,
+        query: queryOne,
+      },
+      {
+        requestPolicy: 'cache-and-network',
+      }
+    );
+
+    const opMutation = client.createRequestOperation('mutation', {
+      key: 2,
+      query: mutation,
+    });
+
+    const response = jest.fn(
+      (forwardOp: Operation): OperationResult => {
+        if (forwardOp.key === 1) {
+          return { operation: opOne, data: queryOneData };
+        }
+
+        return undefined as any;
+      }
+    );
+
+    const result = jest.fn();
+    const forward: ExchangeIO = ops$ =>
+      pipe(
+        ops$,
+        delay(1),
+        filter(x => x.operationName !== 'mutation'),
+        map(response)
+      );
+
+    const optimistic = {
+      concealAuthor: jest.fn(() => optimisticMutationData.concealAuthor) as any,
+    };
+
+    pipe(
+      cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(opOne);
+    jest.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+
+    next(opMutation);
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(optimistic.concealAuthor).toHaveBeenCalledTimes(1);
+    expect(reexec).toHaveBeenCalledTimes(1);
+
+    expect(reexec.mock.calls[0][0]).toHaveProperty(
+      'context.requestPolicy',
+      'cache-first'
+    );
+
+    jest.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+
+    next(opOne);
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(reexec).toHaveBeenCalledTimes(1);
+  });
+
   it('correctly clears on error', () => {
     jest.useFakeTimers();
 
@@ -1529,7 +1727,7 @@ describe('commutativity', () => {
       },
     });
 
-    expect(reexec).toHaveBeenCalledTimes(1);
+    expect(reexec).toHaveBeenCalledTimes(3);
     expect(data).toHaveProperty('node.name', 'mutation');
 
     nextRes({
@@ -1544,7 +1742,7 @@ describe('commutativity', () => {
       },
     });
 
-    expect(reexec).toHaveBeenCalledTimes(2);
+    expect(reexec).toHaveBeenCalledTimes(4);
     expect(data).toHaveProperty('node.name', 'mutation');
   });
 
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index 026750f9..c2272c13 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -25,10 +25,10 @@ describe('garbage collection', () => {
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Todo:1',
-      'Query.todo',
-    ]);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Todo:1': true,
+      'Query.todo': true,
+    });
   });
 
   it('keeps readopted entities', () => {
@@ -45,11 +45,11 @@ describe('garbage collection', () => {
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Todo:1',
-      'Query.todo',
-      'Query.newTodo',
-    ]);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Todo:1': true,
+      'Query.todo': true,
+      'Query.newTodo': true,
+    });
   });
 
   it('keeps entities with multiple owners', () => {
@@ -66,11 +66,11 @@ describe('garbage collection', () => {
     expect(InMemoryData.readLink('Query', 'todoB')).toBe('Todo:1');
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Todo:1',
-      'Query.todoA',
-      'Query.todoB',
-    ]);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Todo:1': true,
+      'Query.todoA': true,
+      'Query.todoB': true,
+    });
   });
 
   it('skips entities with optimistic updates', () => {
@@ -87,14 +87,14 @@ describe('garbage collection', () => {
 
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
 
-    InMemoryData.clearLayer(data, 1);
+    InMemoryData.reserveLayer(data, 1);
     InMemoryData.gc();
-    expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Query.todo',
-      'Todo:1',
-    ]);
+    expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Query.todo': true,
+      'Todo:1': true,
+    });
   });
 
   it('erases child entities that are orphaned', () => {
@@ -111,11 +111,11 @@ describe('garbage collection', () => {
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
     expect(InMemoryData.readRecord('Author:1', 'id')).toBe(undefined);
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Author:1',
-      'Todo:1',
-      'Query.todo',
-    ]);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Author:1': true,
+      'Todo:1': true,
+      'Query.todo': true,
+    });
   });
 });
 
@@ -156,17 +156,17 @@ describe('inspectFields', () => {
       ]
     `);
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual([
-      'Query.todo({"id":"1"})',
-      'Query.hasTodo({"id":"1"})',
-      'Query.randomTodo',
-    ]);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({
+      'Query.todo({"id":"1"})': true,
+      'Query.hasTodo({"id":"1"})': true,
+      'Query.randomTodo': true,
+    });
   });
 
   it('returns an empty array when an entity is unknown', () => {
     expect(InMemoryData.inspectFields('Random')).toEqual([]);
 
-    expect([...InMemoryData.getCurrentDependencies()]).toEqual(['Random']);
+    expect(InMemoryData.getCurrentDependencies()).toEqual({ Random: true });
   });
 
   it('returns field infos for all optimistic updates', () => {
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index 3a08503a..1e578178 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -140,7 +140,7 @@ describe('Store with OptimisticMutationConfig', () => {
     expect(result).toEqual('Go to the shops');
     // TODO: we have no way of asserting this to really be the case.
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(new Set(['Todo:0', 'Author:0']));
+    expect(deps).toEqual({ 'Todo:0': true, 'Author:0': true });
     InMemoryData.clearDataState();
   });
 
@@ -148,7 +148,7 @@ describe('Store with OptimisticMutationConfig', () => {
     const authorResult = store.resolve('Author:0', 'name');
     expect(authorResult).toBe('Jovi');
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(new Set(['Author:0']));
+    expect(deps).toEqual({ 'Author:0': true });
     InMemoryData.clearDataState();
   });
 
@@ -162,7 +162,7 @@ describe('Store with OptimisticMutationConfig', () => {
     const result = store.resolve(parent, 'author');
     expect(result).toEqual('Author:0');
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(new Set(['Todo:0']));
+    expect(deps).toEqual({ 'Todo:0': true });
     InMemoryData.clearDataState();
   });
 
@@ -276,7 +276,7 @@ describe('Store with OptimisticMutationConfig', () => {
     );
 
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(new Set(['Todo:0']));
+    expect(deps).toEqual({ 'Todo:0': true });
 
     const { data } = query(store, { query: Todos });
 
@@ -308,7 +308,7 @@ describe('Store with OptimisticMutationConfig', () => {
     );
 
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(new Set(['Todo:0']));
+    expect(deps).toEqual({ 'Todo:0': true });
 
     expect(result).toEqual({
       id: '0',
@@ -410,16 +410,14 @@ describe('Store with OptimisticMutationConfig', () => {
     const result = store.readQuery({ query: Todos });
 
     const deps = InMemoryData.getCurrentDependencies();
-    expect(deps).toEqual(
-      new Set([
-        'Query.todos',
-        'Todo:0',
-        'Todo:1',
-        'Todo:2',
-        'Author:0',
-        'Author:1',
-      ])
-    );
+    expect(deps).toEqual({
+      'Query.todos': true,
+      'Todo:0': true,
+      'Todo:1': true,
+      'Todo:2': true,
+      'Author:0': true,
+      'Author:1': true,
+    });
 
     expect(result).toEqual({
       __typename: 'Query',
@@ -450,7 +448,7 @@ describe('Store with OptimisticMutationConfig', () => {
       },
       1
     );
-    expect(dependencies).toEqual(new Set(['Todo:1']));
+    expect(dependencies).toEqual({ 'Todo:1': true });
     let { data } = query(store, { query: Todos });
     expect(data).toEqual({
       __typename: 'Query',
@@ -471,7 +469,8 @@ describe('Store with OptimisticMutationConfig', () => {
       ],
     });
 
-    InMemoryData.clearLayer(store.data, 1);
+    InMemoryData.noopDataState(store.data, 1);
+
     ({ data } = query(store, { query: Todos }));
     expect(data).toEqual({
       __typename: 'Query',
diff --git a/exchanges/graphcache/src/test-utils/examples-1.test.ts b/exchanges/graphcache/src/test-utils/examples-1.test.ts
index 30195e16..eb3498f2 100644
--- a/exchanges/graphcache/src/test-utils/examples-1.test.ts
+++ b/exchanges/graphcache/src/test-utils/examples-1.test.ts
@@ -67,8 +67,12 @@ it('passes the "getting-started" example', () => {
 
   const writeRes = write(store, { query: Todos }, todosData);
 
-  const expectedSet = new Set(['Query.todos', 'Todo:0', 'Todo:1', 'Todo:2']);
-  expect(writeRes.dependencies).toEqual(expectedSet);
+  expect(writeRes.dependencies).toEqual({
+    'Query.todos': true,
+    'Todo:0': true,
+    'Todo:1': true,
+    'Todo:2': true,
+  });
 
   let queryRes = query(store, { query: Todos });
 
@@ -90,7 +94,7 @@ it('passes the "getting-started" example', () => {
     }
   );
 
-  expect(mutationRes.dependencies).toEqual(new Set(['Todo:2']));
+  expect(mutationRes.dependencies).toEqual({ 'Todo:2': true });
 
   queryRes = query(store, { query: Todos });
 
@@ -117,7 +121,7 @@ it('passes the "getting-started" example', () => {
     }
   );
 
-  expect(newMutationRes.dependencies).toEqual(new Set(['Todo:2']));
+  expect(newMutationRes.dependencies).toEqual({ 'Todo:2': true });
 
   queryRes = query(store, { query: Todos });
 
@@ -212,8 +216,12 @@ it('respects property-level resolvers when given', () => {
 
   const writeRes = write(store, { query: Todos }, todosData);
 
-  const expectedSet = new Set(['Query.todos', 'Todo:0', 'Todo:1', 'Todo:2']);
-  expect(writeRes.dependencies).toEqual(expectedSet);
+  expect(writeRes.dependencies).toEqual({
+    'Query.todos': true,
+    'Todo:0': true,
+    'Todo:1': true,
+    'Todo:2': true,
+  });
 
   let queryRes = query(store, { query: Todos });
 
@@ -242,7 +250,7 @@ it('respects property-level resolvers when given', () => {
     }
   );
 
-  expect(mutationRes.dependencies).toEqual(new Set(['Todo:2']));
+  expect(mutationRes.dependencies).toEqual({ 'Todo:2': true });
 
   queryRes = query(store, { query: Todos });
 
@@ -425,7 +433,7 @@ it('correctly resolves optimistic updates on Relay schemas', () => {
 
   write(store, { query: getRoot }, queryData);
   writeOptimistic(store, { query: updateItem, variables: { id: '2' } }, 1);
-  InMemoryData.clearLayer(store.data, 1);
+  InMemoryData.noopDataState(store.data, 1);
   const queryRes = query(store, { query: getRoot });
 
   expect(queryRes.partial).toBe(false);
diff --git a/exchanges/graphcache/src/test-utils/examples-2.test.ts b/exchanges/graphcache/src/test-utils/examples-2.test.ts
index 486b99aa..4eb674f2 100644
--- a/exchanges/graphcache/src/test-utils/examples-2.test.ts
+++ b/exchanges/graphcache/src/test-utils/examples-2.test.ts
@@ -222,7 +222,7 @@ it('allows custom resolvers to resolve mixed data (keyable and unkeyable)', () =
 
   const res = query(store, { query: ItemDetailed });
   expect(res.partial).toBe(false);
-  expect(Array.from(res.dependencies).includes('Author:x')).toBe(true);
+  expect(res.dependencies).toHaveProperty('Author:x', true);
   expect(res.data).toEqual({
     __typename: 'Query',
     todo: {