Repository: FormidableLabs/urql
Pull request #611 (2020-03-12 21:45:58)

(graphcache) - Reduce overall bundle-size
## Summary

This (work in progress) PR attempts to reduce `@urql/exchange-graphcache`'s overall bundle size by rewriting a couple of larger functions and reducing duplicate code paths whenever possible.

## Set of changes

- Deprecate `store.invalidateQuery` and replace the `invalidate` operation with a shim based on the `query` operation.
- Share `Context` between all operations using `makeContext` and `updateContext` helpers (the latter being for `info`)
- Combine `readSelection` and `readResolverResult`
- Combine `writeField` and `writeRootField`
- Simplify several helper utilities
- Simplify Store constructor and remove `Store.gc`
- Simplify cache execution in `cacheExchange`
- Remove redundant code in `data.ts`

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

Latest commit: 839982d2ade46644a0e5ce40b22ffcda19a19125

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/refactor/reduce-graphcache-size?filename=.changeset/honest-dryers-roll.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0AWIP%3A%20(graphcache)%20-%20Reduce%20overall%20bundle-size%0A)



Patch:
diff --git a/.changeset/new-humans-carry.md b/.changeset/new-humans-carry.md
new file mode 100644
index 00000000..d191bc57
--- /dev/null
+++ b/.changeset/new-humans-carry.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Refactor parts of Graphcache for a minor performance boost and bundlesize reductions.
diff --git a/exchanges/graphcache/benchmark/suite.js b/exchanges/graphcache/benchmark/suite.js
index bdb72add..461802b4 100644
--- a/exchanges/graphcache/benchmark/suite.js
+++ b/exchanges/graphcache/benchmark/suite.js
@@ -87,60 +87,6 @@ suite('10,000 entries write', () => {
   });
 });
 
-suite('100 entries GC', () => {
-  const urqlStore = new Store();
-  const apolloCache = new InMemoryCache({ resultCaching: false });
-
-  benchmark('apollo', () => {
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: hundredEntries } })
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } })
-    apolloCache.gc();
-  });
-
-  benchmark('urql', () => {
-    write(urqlStore, { query: TodosQuery }, { todos: hundredEntries });
-    write(urqlStore, { query: TodosQuery }, { todos: [] });
-    urqlStore.gcScheduled = false;
-    urqlStore.gc();
-  });
-});
-
-suite('1,000 entries GC', () => {
-  const urqlStore = new Store();
-  const apolloCache = new InMemoryCache({ resultCaching: false });
-
-  benchmark('apollo', () => {
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: thousandEntries } })
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } })
-    apolloCache.gc();
-  });
-
-  benchmark('urql', () => {
-    write(urqlStore, { query: TodosQuery }, { todos: thousandEntries });
-    write(urqlStore, { query: TodosQuery }, { todos: [] });
-    urqlStore.gcScheduled = false;
-    urqlStore.gc();
-  });
-});
-
-suite('10,000 entries GC', () => {
-  const urqlStore = new Store();
-  const apolloCache = new InMemoryCache({ resultCaching: false });
-
-  benchmark('apollo', () => {
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: tenThousandEntries } })
-    apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } })
-    apolloCache.gc();
-  });
-
-  benchmark('urql', () => {
-    write(urqlStore, { query: TodosQuery }, { todos: tenThousandEntries });
-    write(urqlStore, { query: TodosQuery }, { todos: [] });
-    urqlStore.gcScheduled = false;
-    urqlStore.gc();
-  });
-});
-
 suite('100 entries read', () => {
   const urqlStore = new Store();
   const apolloCache = new InMemoryCache({ resultCaching: false });;
diff --git a/exchanges/graphcache/src/ast/node.ts b/exchanges/graphcache/src/ast/node.ts
index d773f86b..19341456 100644
--- a/exchanges/graphcache/src/ast/node.ts
+++ b/exchanges/graphcache/src/ast/node.ts
@@ -6,14 +6,10 @@ import {
   InlineFragmentNode,
   FieldNode,
   FragmentDefinitionNode,
-  GraphQLOutputType,
   Kind,
-  isWrappingType,
-  GraphQLWrappingType,
 } from 'graphql';
 
 export type SelectionSet = ReadonlyArray<SelectionNode>;
-export type GraphQLFlatType = Exclude<GraphQLOutputType, GraphQLWrappingType>;
 
 /** Returns the name of a given node */
 export const getName = (node: { name: NameNode }): string => node.name.value;
@@ -23,20 +19,18 @@ export const getFragmentTypeName = (node: FragmentDefinitionNode): string =>
 
 /** Returns either the field's name or the field's alias */
 export const getFieldAlias = (node: FieldNode): string =>
-  node.alias !== undefined ? node.alias.value : getName(node);
+  node.alias ? node.alias.value : getName(node);
 
 /** Returns the SelectionSet for a given inline or defined fragment node */
 export const getSelectionSet = (node: {
   selectionSet?: SelectionSetNode;
-}): SelectionSet =>
-  node.selectionSet !== undefined ? node.selectionSet.selections : [];
+}): SelectionSet => (node.selectionSet ? node.selectionSet.selections : []);
 
 export const getTypeCondition = ({
   typeCondition,
 }: {
   typeCondition?: NamedTypeNode;
-}): string | null =>
-  typeCondition !== undefined ? getName(typeCondition) : null;
+}): string | null => (typeCondition ? getName(typeCondition) : null);
 
 export const isFieldNode = (node: SelectionNode): node is FieldNode =>
   node.kind === Kind.FIELD;
@@ -44,13 +38,3 @@ export const isFieldNode = (node: SelectionNode): node is FieldNode =>
 export const isInlineFragment = (
   node: SelectionNode
 ): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
-
-export const unwrapType = (
-  type: null | undefined | GraphQLOutputType
-): GraphQLFlatType | null => {
-  if (isWrappingType(type)) {
-    return unwrapType(type.ofType);
-  }
-
-  return type || null;
-};
diff --git a/exchanges/graphcache/src/ast/schemaPredicates.ts b/exchanges/graphcache/src/ast/schemaPredicates.ts
index bcc3db11..6e0f2333 100644
--- a/exchanges/graphcache/src/ast/schemaPredicates.ts
+++ b/exchanges/graphcache/src/ast/schemaPredicates.ts
@@ -17,8 +17,7 @@ export const isFieldNullable = (
   fieldName: string
 ): boolean => {
   const field = getField(schema, typename, fieldName);
-  if (field === undefined) return false;
-  return isNullableType(field.type);
+  return !!field && isNullableType(field.type);
 };
 
 export const isListNullable = (
@@ -27,7 +26,7 @@ export const isListNullable = (
   fieldName: string
 ): boolean => {
   const field = getField(schema, typename, fieldName);
-  if (field === undefined) return false;
+  if (!field) return false;
   const ofType = isNonNullType(field.type) ? field.type.ofType : field.type;
   return isListType(ofType) && isNullableType(ofType.ofType);
 };
@@ -69,7 +68,7 @@ const getField = (
   expectObjectType(object, typename);
 
   const field = object.getFields()[fieldName];
-  if (field === undefined) {
+  if (!field) {
     warn(
       'Invalid field: The field `' +
         fieldName +
@@ -80,8 +79,6 @@ const getField = (
         'Traversal will continue, however this may lead to undefined behavior!',
       4
     );
-
-    return undefined;
   }
 
   return field;
diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts
index 4f58e102..df0ee721 100644
--- a/exchanges/graphcache/src/ast/traversal.ts
+++ b/exchanges/graphcache/src/ast/traversal.ts
@@ -46,29 +46,24 @@ export const shouldInclude = (
   vars: Variables
 ): boolean => {
   const { directives } = node;
-  if (directives === undefined) {
-    return true;
-  }
+  if (!directives) return true;
 
   // Finds any @include or @skip directive that forces the node to be skipped
   for (let i = 0, l = directives.length; i < l; i++) {
     const directive = directives[i];
     const name = getName(directive);
 
-    // Ignore other directives
-    const isInclude = name === 'include';
-    if (!isInclude && name !== 'skip') continue;
-
-    // Get the first argument and expect it to be named "if"
-    const arg = directive.arguments ? directive.arguments[0] : null;
-    if (!arg || getName(arg) !== 'if') continue;
-
-    const value = valueFromASTUntyped(arg.value, vars);
-    if (typeof value !== 'boolean' && value !== null) continue;
-
-    // Return whether this directive forces us to skip
-    // `@include(if: false)` or `@skip(if: true)`
-    return isInclude ? !!value : !value;
+    if (
+      (name === 'include' || name === 'skip') &&
+      directive.arguments &&
+      directive.arguments[0] &&
+      getName(directive.arguments[0]) === 'if'
+    ) {
+      // Return whether this directive forces us to skip
+      // `@include(if: false)` or `@skip(if: true)`
+      const value = valueFromASTUntyped(directive.arguments[0].value, vars);
+      return name === 'include' ? !!value : !value;
+    }
   }
 
   return true;
diff --git a/exchanges/graphcache/src/ast/variables.ts b/exchanges/graphcache/src/ast/variables.ts
index c05af732..2831d0ce 100644
--- a/exchanges/graphcache/src/ast/variables.ts
+++ b/exchanges/graphcache/src/ast/variables.ts
@@ -14,19 +14,16 @@ export const getFieldArguments = (
   node: FieldNode,
   vars: Variables
 ): null | Variables => {
-  if (node.arguments === undefined || node.arguments.length === 0) {
-    return null;
-  }
-
   const args = makeDict();
   let argsSize = 0;
-
-  for (let i = 0, l = node.arguments.length; i < l; i++) {
-    const arg = node.arguments[i];
-    const value = valueFromASTUntyped(arg.value, vars);
-    if (value !== undefined && value !== null) {
-      args[getName(arg)] = value;
-      argsSize++;
+  if (node.arguments && node.arguments.length) {
+    for (let i = 0, l = node.arguments.length; i < l; i++) {
+      const arg = node.arguments[i];
+      const value = valueFromASTUntyped(arg.value, vars);
+      if (value !== undefined && value !== null) {
+        args[getName(arg)] = value;
+        argsSize++;
+      }
     }
   }
 
@@ -38,24 +35,20 @@ export const normalizeVariables = (
   node: OperationDefinitionNode,
   input: void | object
 ): Variables => {
-  if (node.variableDefinitions === undefined) {
-    return {};
-  }
-
   const args: Variables = (input as Variables) || {};
-
-  return node.variableDefinitions.reduce((vars, def) => {
-    const name = getName(def.variable);
-    let value = args[name];
-    if (value === undefined) {
-      if (def.defaultValue !== undefined) {
+  const vars = makeDict();
+  if (node.variableDefinitions) {
+    for (let i = 0, l = node.variableDefinitions.length; i < l; i++) {
+      const def = node.variableDefinitions[i];
+      const name = getName(def.variable);
+      let value = args[name];
+      if (value === undefined && def.defaultValue) {
         value = valueFromASTUntyped(def.defaultValue, args);
-      } else {
-        return vars;
       }
+
+      vars[name] = value;
     }
+  }
 
-    vars[name] = value;
-    return vars;
-  }, makeDict());
+  return vars;
 };
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index 979fb06a..0d66a67e 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -61,37 +61,6 @@ const addCacheOutcome = (op: Operation, outcome: CacheOutcome): Operation => ({
   },
 });
 
-// Returns the given operation with added __typename fields on its query
-const addTypeNames = (op: Operation): Operation => ({
-  ...op,
-  query: formatDocument(op.query),
-});
-
-// Retrieves the requestPolicy from an operation
-const getRequestPolicy = (op: Operation) => op.context.requestPolicy;
-
-// Returns whether an operation is a query
-const isQueryOperation = (op: Operation): boolean =>
-  op.operationName === 'query';
-
-// Returns whether an operation is a mutation
-const isMutationOperation = (op: Operation): boolean =>
-  op.operationName === 'mutation';
-
-// Returns whether an operation is a subscription
-const isSubscriptionOperation = (op: Operation): boolean =>
-  op.operationName === 'subscription';
-
-// Returns whether an operation can potentially be read from cache
-const isCacheableQuery = (op: Operation): boolean => {
-  return isQueryOperation(op) && getRequestPolicy(op) !== 'network-only';
-};
-
-// Returns whether an operation potentially triggers an optimistic update
-const isOptimisticMutation = (op: Operation): boolean => {
-  return isMutationOperation(op) && getRequestPolicy(op) !== 'network-only';
-};
-
 // Copy an operation and change the requestPolicy to skip the cache
 const toRequestPolicy = (
   operation: Operation,
@@ -129,9 +98,8 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
 
   let hydration: void | Promise<void>;
   if (opts.storage) {
-    const storage = opts.storage;
-    hydration = storage.read().then(entries => {
-      hydrateData(store.data, storage, entries);
+    hydration = opts.storage.read().then(entries => {
+      hydrateData(store.data, opts!.storage!, entries);
     });
   }
 
@@ -143,11 +111,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     pendingOperations: Set<number>,
     dependencies: void | Set<string>
   ) => {
-    if (dependencies !== undefined) {
+    if (dependencies) {
       // Collect operations that will be updated due to cache changes
       dependencies.forEach(dep => {
         const keys = deps[dep];
-        if (keys !== undefined) {
+        if (keys) {
           deps[dep] = [];
           for (let i = 0, l = keys.length; i < l; i++) {
             pendingOperations.add(keys[i]);
@@ -165,7 +133,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     pendingOperations.forEach(key => {
       if (key !== operation.key) {
         const op = ops.get(key);
-        if (op !== undefined) {
+        if (op) {
           ops.delete(key);
           client.reexecuteOperation(toRequestPolicy(op, 'cache-first'));
         }
@@ -175,7 +143,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
 
   // This registers queries with the data layer to ensure commutativity
   const prepareCacheForResult = (operation: Operation) => {
-    if (isQueryOperation(operation)) {
+    if (operation.operationName === 'query') {
       reserveLayer(store.data, operation.key);
     } else if (operation.operationName === 'teardown') {
       noopDataState(store.data, operation.key);
@@ -185,7 +153,10 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
   // This executes an optimistic update for mutations and registers it if necessary
   const optimisticUpdate = (operation: Operation) => {
     const { key } = operation;
-    if (isOptimisticMutation(operation)) {
+    if (
+      operation.operationName === 'mutation' &&
+      operation.context.requestPolicy !== 'network-only'
+    ) {
       const { dependencies } = writeOptimistic(store, operation, key);
       if (dependencies.size !== 0) {
         optimisticKeysToDependencies.set(key, dependencies);
@@ -201,13 +172,12 @@ 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 keys = deps[dep] || (deps[dep] = []);
-      keys.push(op.key);
+      (deps[dep] || (deps[dep] = [])).push(op.key);
 
       if (!ops.has(op.key)) {
         ops.set(
           op.key,
-          getRequestPolicy(op) === 'network-only'
+          op.context.requestPolicy === 'network-only'
             ? toRequestPolicy(op, 'cache-and-network')
             : op
         );
@@ -220,78 +190,71 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
   const operationResultFromCache = (
     operation: Operation
   ): OperationResultWithMeta => {
-    const { data, dependencies, partial } = query(store, operation);
-    let cacheOutcome: CacheOutcome;
-
-    if (data === null) {
-      cacheOutcome = 'miss';
-    } else {
-      updateDependencies(operation, dependencies);
-      cacheOutcome =
-        !partial || getRequestPolicy(operation) === 'cache-only'
-          ? 'hit'
-          : 'partial';
+    const res = query(store, operation);
+    const cacheOutcome: CacheOutcome = res.data
+      ? !res.partial || operation.context.requestPolicy === 'cache-only'
+        ? 'hit'
+        : 'partial'
+      : 'miss';
+
+    if (res.data) {
+      updateDependencies(operation, res.dependencies);
     }
 
     return {
       outcome: cacheOutcome,
       operation,
-      data,
+      data: res.data,
     };
   };
 
   // Take any OperationResult and update the cache with it
   const updateCacheWithResult = (result: OperationResult): OperationResult => {
     const { operation, error, extensions } = result;
-    const isQuery = isQueryOperation(operation);
-    let { data } = result;
 
     // Clear old optimistic values from the store
     const { key } = operation;
     const pendingOperations = new Set<number>();
 
-    if (isMutationOperation(operation)) {
+    if (operation.operationName === 'mutation') {
+      // Collect previous dependencies that have been written for optimistic updates
       collectPendingOperations(
         pendingOperations,
         optimisticKeysToDependencies.get(key)
       );
       optimisticKeysToDependencies.delete(key);
-    } else if (isSubscriptionOperation(operation)) {
+    } else if (operation.operationName === 'subscription') {
       // If we're writing a subscription, we ad-hoc reserve a layer
       reserveLayer(store.data, operation.key);
     }
 
-    let writeDependencies: Set<string> | void;
     let queryDependencies: Set<string> | void;
-    if (data !== null && data !== undefined) {
-      writeDependencies = write(store, operation, data, key).dependencies;
-
-      if (isQuery) {
-        const queryResult = query(store, operation);
-        data = queryResult.data;
+    if (result.data) {
+      // Write the result to cache and collect all dependencies that need to be
+      // updated
+      const writeDependencies = write(store, operation, result.data, key)
+        .dependencies;
+      collectPendingOperations(pendingOperations, writeDependencies);
+
+      const queryResult = query(store, operation, result.data);
+      result.data = queryResult.data;
+      if (operation.operationName === 'query') {
+        // Collect the query's dependencies for future pending operation updates
         queryDependencies = queryResult.dependencies;
-      } else {
-        data = query(store, operation, data).data;
+        collectPendingOperations(pendingOperations, queryDependencies);
       }
     } else {
       noopDataState(store.data, operation.key);
     }
 
-    // Collect all write dependencies and query dependencies for queries
-    collectPendingOperations(pendingOperations, writeDependencies);
-    if (isQuery) {
-      collectPendingOperations(pendingOperations, queryDependencies);
-    }
-
     // Execute all pending operations related to changed dependencies
     executePendingOperations(result.operation, pendingOperations);
-
     // Update this operation's dependencies if it's a query
-    if (isQuery && queryDependencies !== undefined) {
+    if (queryDependencies) {
       updateDependencies(result.operation, queryDependencies);
     }
 
-    return { data, error, extensions, operation };
+    return { data: result.data, error, extensions, operation };
   };
 
   return ops$ => {
@@ -310,7 +273,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
 
     const inputOps$ = pipe(
       concat([bufferedOps$, sharedOps$]),
-      map(addTypeNames),
+      // Returns the given operation with added __typename fields on its query
+      map(op => ({
+        ...op,
+        query: formatDocument(op.query),
+      })),
       tap(optimisticUpdate),
       share
     );
@@ -318,7 +285,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     // Filter by operations that are cacheable and attempt to query them from the cache
     const cache$ = pipe(
       inputOps$,
-      filter(op => isCacheableQuery(op)),
+      filter(
+        op =>
+          op.operationName === 'query' &&
+          op.context.requestPolicy !== 'network-only'
+      ),
       map(operationResultFromCache),
       share
     );
@@ -327,7 +298,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
     const cacheOps$ = pipe(
       cache$,
       filter(res => res.outcome === 'miss'),
-      map(res => addCacheOutcome(res.operation, res.outcome))
+      map(res => addCacheOutcome(res.operation, 'miss'))
     );
 
     // Resolve OperationResults that the cache was able to assemble completely and trigger
@@ -338,7 +309,6 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       map(
         (res: OperationResultWithMeta): OperationResult => {
           const { operation, outcome } = res;
-          const policy = getRequestPolicy(operation);
           const result: OperationResult = {
             operation: addCacheOutcome(operation, outcome),
             data: res.data,
@@ -347,8 +317,9 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
           };
 
           if (
-            policy === 'cache-and-network' ||
-            (policy === 'cache-first' && outcome === 'partial')
+            operation.context.requestPolicy === 'cache-and-network' ||
+            (operation.context.requestPolicy === 'cache-first' &&
+              outcome === 'partial')
           ) {
             result.stale = true;
             client.reexecuteOperation(
@@ -367,7 +338,13 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
       merge([
         pipe(
           inputOps$,
-          filter(op => !isCacheableQuery(op))
+          filter(
+            op =>
+              !(
+                op.operationName === 'query' &&
+                op.context.requestPolicy !== 'network-only'
+              )
+          )
         ),
         cacheOps$,
       ]),
diff --git a/exchanges/graphcache/src/index.ts b/exchanges/graphcache/src/index.ts
index 9217895e..6dfa3b41 100644
--- a/exchanges/graphcache/src/index.ts
+++ b/exchanges/graphcache/src/index.ts
@@ -1,5 +1,5 @@
-export * from './operations';
 export * from './types';
-export { Store, initDataState, clearDataState } from './store';
+export { query, write, writeOptimistic } from './operations';
+export { Store, noopDataState, reserveLayer } from './store';
 export { cacheExchange } from './cacheExchange';
 export { populateExchange } from '@urql/exchange-populate';
diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts
index 83b1acdb..6ad3ef52 100644
--- a/exchanges/graphcache/src/operations/invalidate.ts
+++ b/exchanges/graphcache/src/operations/invalidate.ts
@@ -1,94 +1,22 @@
-import { FieldNode } from 'graphql';
-import { getSelectionSet, getName, SelectionSet } from '../ast/node';
-
-import {
-  getMainOperation,
-  normalizeVariables,
-  getFragments,
-  getFieldArguments,
-} from '../ast';
-
-import { EntityField, OperationRequest, Variables, Fragments } from '../types';
-
 import * as InMemoryData from '../store/data';
-import { Store, keyOfField } from '../store';
-import { isFieldAvailableOnType } from '../ast';
-import { SelectionIterator } from './shared';
-
-interface Context {
-  store: Store;
-  variables: Variables;
-  fragments: Fragments;
-}
+import { OperationRequest } from '../types';
+import { Store } from '../store';
+import { read } from './query';
 
 export const invalidate = (store: Store, request: OperationRequest) => {
-  const operation = getMainOperation(request.query);
-
-  const ctx: Context = {
-    variables: normalizeVariables(operation, request.variables),
-    fragments: getFragments(request.query),
-    store,
-  };
-
-  invalidateSelection(
-    ctx,
-    ctx.store.getRootKey('query'),
-    getSelectionSet(operation)
-  );
-};
-
-export const invalidateSelection = (
-  ctx: Context,
-  entityKey: string,
-  select: SelectionSet
-) => {
-  const isQuery = entityKey === 'Query';
-
-  let typename: EntityField;
-  if (!isQuery) {
-    typename = InMemoryData.readRecord(entityKey, '__typename');
-    if (typeof typename !== 'string') {
-      return;
+  const dependencies = InMemoryData.forkDependencies();
+  read(store, request);
+
+  dependencies.forEach(dependency => {
+    if (dependency.startsWith(`${store.data.queryRootKey}.`)) {
+      const fieldKey = dependency.slice(`${store.data.queryRootKey}.`.length);
+      InMemoryData.writeLink(store.data.queryRootKey, fieldKey);
+      InMemoryData.writeRecord(store.data.queryRootKey, fieldKey);
     } else {
-      InMemoryData.writeRecord(entityKey, '__typename', undefined);
+      store.invalidate(dependency);
     }
-  } else {
-    typename = entityKey;
-  }
+  });
 
-  const iter = new SelectionIterator(typename, entityKey, select, ctx);
-
-  let node: FieldNode | void;
-  while ((node = iter.next()) !== undefined) {
-    const fieldName = getName(node);
-    const fieldKey = keyOfField(
-      fieldName,
-      getFieldArguments(node, ctx.variables)
-    );
-
-    if (process.env.NODE_ENV !== 'production' && ctx.store.schema && typename) {
-      isFieldAvailableOnType(ctx.store.schema, typename, fieldName);
-    }
-
-    if (node.selectionSet === undefined) {
-      InMemoryData.writeRecord(entityKey, fieldKey, undefined);
-    } else {
-      const fieldSelect = getSelectionSet(node);
-      const link = InMemoryData.readLink(entityKey, fieldKey);
-
-      InMemoryData.writeLink(entityKey, fieldKey, undefined);
-      InMemoryData.writeRecord(entityKey, fieldKey, undefined);
-
-      if (Array.isArray(link)) {
-        for (let i = 0, l = link.length; i < l; i++) {
-          const childLink = link[i];
-          if (childLink !== null) {
-            invalidateSelection(ctx, childLink, fieldSelect);
-          }
-        }
-      } else if (link) {
-        invalidateSelection(ctx, link, fieldSelect);
-      }
-    }
-  }
+  InMemoryData.unforkDependencies();
+  InMemoryData.gc(store.data);
 };
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 678242db..f570e687 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -1,4 +1,5 @@
 import { FieldNode, DocumentNode, FragmentDefinitionNode } from 'graphql';
+
 import {
   getSelectionSet,
   getName,
@@ -6,7 +7,6 @@ import {
   getFragmentTypeName,
   getFieldAlias,
 } from '../ast';
-import { warn, pushDebugNode } from '../helpers/help';
 
 import {
   getFragments,
@@ -16,7 +16,6 @@ import {
 } from '../ast';
 
 import {
-  Fragments,
   Variables,
   Data,
   DataField,
@@ -35,8 +34,16 @@ import {
 } from '../store';
 
 import * as InMemoryData from '../store/data';
+import { warn, pushDebugNode } from '../helpers/help';
 import { makeDict } from '../helpers/dict';
-import { SelectionIterator, ensureData } from './shared';
+
+import {
+  Context,
+  SelectionIterator,
+  ensureData,
+  makeContext,
+  updateContext,
+} from './shared';
 
 import {
   isFieldAvailableOnType,
@@ -50,17 +57,6 @@ export interface QueryResult {
   data: null | Data;
 }
 
-interface Context {
-  parentTypeName: string;
-  parentKey: string;
-  parentFieldKey: string;
-  fieldName: string;
-  store: Store;
-  variables: Variables;
-  fragments: Fragments;
-  partial: boolean;
-}
-
 export const query = (
   store: Store,
   request: OperationRequest,
@@ -78,19 +74,16 @@ export const read = (
   input?: Data
 ): QueryResult => {
   const operation = getMainOperation(request.query);
-  const rootKey = store.getRootKey(operation.operation);
+  const rootKey = store.rootFields[operation.operation];
   const rootSelect = getSelectionSet(operation);
 
-  const ctx: Context = {
-    parentTypeName: rootKey,
-    parentKey: rootKey,
-    parentFieldKey: '',
-    fieldName: '',
+  const ctx = makeContext(
     store,
-    variables: normalizeVariables(operation, request.variables),
-    fragments: getFragments(request.query),
-    partial: false,
-  };
+    normalizeVariables(operation, request.variables),
+    getFragments(request.query),
+    rootKey,
+    rootKey
+  );
 
   if (process.env.NODE_ENV !== 'production') {
     pushDebugNode(rootKey, operation);
@@ -98,7 +91,7 @@ export const read = (
 
   let data = input || makeDict();
   data =
-    rootKey !== ctx.store.getRootKey('query')
+    rootKey !== ctx.store.rootFields['query']
       ? readRoot(ctx, rootKey, rootSelect, data)
       : readSelection(ctx, rootKey, rootSelect, data);
 
@@ -209,16 +202,13 @@ export const readFragment = (
     pushDebugNode(typename, fragment);
   }
 
-  const ctx: Context = {
-    parentTypeName: typename,
-    parentKey: entityKey,
-    parentFieldKey: '',
-    fieldName: '',
-    variables: variables || {},
-    fragments,
-    partial: false,
+  const ctx = makeContext(
     store,
-  };
+    variables || {},
+    fragments,
+    typename,
+    entityKey
+  );
 
   return (
     readSelection(ctx, entityKey, getSelectionSet(fragment), makeDict()) || null
@@ -227,21 +217,38 @@ export const readFragment = (
 
 const readSelection = (
   ctx: Context,
-  entityKey: string,
+  key: string,
   select: SelectionSet,
-  data: Data
+  data: Data,
+  result?: Data
 ): Data | undefined => {
   const { store } = ctx;
-  const isQuery = entityKey === store.getRootKey('query');
+  const isQuery = key === store.rootFields['query'];
 
-  // Get the __typename field for a given entity to check that it exists
+  const entityKey = (result && store.keyOfEntity(result)) || key;
   const typename = !isQuery
-    ? InMemoryData.readRecord(entityKey, '__typename')
-    : entityKey;
-  if (typeof typename !== 'string') {
+    ? InMemoryData.readRecord(entityKey, '__typename') ||
+      (result && result.__typename)
+    : key;
+
+  if (
+    typeof typename !== 'string' ||
+    (result && typename !== result.__typename)
+  ) {
+    // TODO: This may be an invalid error for resolvers that return interfaces
+    warn(
+      'Invalid resolver data: The resolver at `' +
+        entityKey +
+        '` returned an ' +
+        'invalid typename that could not be reconciled with the cache.',
+      8
+    );
+
     return undefined;
   }
 
+  // The following closely mirrors readSelection, but differs only slightly for the
+  // sake of resolving from an existing resolver result
   data.__typename = typename;
   const iter = new SelectionIterator(typename, entityKey, select, ctx);
 
@@ -254,8 +261,10 @@ const readSelection = (
     const fieldArgs = getFieldArguments(node, ctx.variables);
     const fieldAlias = getFieldAlias(node);
     const fieldKey = keyOfField(fieldName, fieldArgs);
-    const fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
     const key = joinKeys(entityKey, fieldKey);
+    const fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
+    const resultValue = result ? result[fieldName] : undefined;
+    const resolvers = store.resolvers[typename];
 
     if (process.env.NODE_ENV !== 'production' && store.schema && typename) {
       isFieldAvailableOnType(store.schema, typename, fieldName);
@@ -265,14 +274,13 @@ const readSelection = (
     // means that the value is missing from the cache
     let dataFieldValue: void | DataField;
 
-    const resolvers = store.resolvers[typename];
-    if (resolvers !== undefined && typeof resolvers[fieldName] === 'function') {
+    if (resultValue !== undefined && node.selectionSet === undefined) {
+      // The field is a scalar and can be retrieved directly from the result
+      dataFieldValue = resultValue;
+    } else if (resolvers && typeof resolvers[fieldName] === 'function') {
       // We have to update the information in context to reflect the info
       // that the resolver will receive
-      ctx.parentTypeName = typename;
-      ctx.parentKey = entityKey;
-      ctx.parentFieldKey = key;
-      ctx.fieldName = fieldName;
+      updateContext(ctx, typename, entityKey, key, fieldName);
 
       // We have a resolver for this field.
       // Prepare the actual fieldValue, so that the resolver can use it
@@ -310,112 +318,6 @@ const readSelection = (
         // current field
         return undefined;
       }
-    } else if (node.selectionSet === undefined) {
-      // The field is a scalar and can be retrieved directly
-      dataFieldValue = fieldValue;
-    } else {
-      // We have a selection set which means that we'll be checking for links
-      const link = InMemoryData.readLink(entityKey, fieldKey);
-      if (link !== undefined) {
-        dataFieldValue = resolveLink(
-          ctx,
-          link,
-          typename,
-          fieldName,
-          getSelectionSet(node),
-          data[fieldAlias] as Data
-        );
-      } else if (typeof fieldValue === 'object' && fieldValue !== null) {
-        // The entity on the field was invalid but can still be recovered
-        dataFieldValue = fieldValue;
-      }
-    }
-
-    // Now that dataFieldValue has been retrieved it'll be set on data
-    // If it's uncached (undefined) but nullable we can continue assembling
-    // a partial query result
-    if (
-      dataFieldValue === undefined &&
-      store.schema &&
-      isFieldNullable(store.schema, typename, fieldName)
-    ) {
-      // The field is uncached but we have a schema that says it's nullable
-      // Set the field to null and continue
-      hasPartials = true;
-      data[fieldAlias] = null;
-    } else if (dataFieldValue === undefined) {
-      // The field is uncached and not nullable; return undefined
-      return undefined;
-    } else {
-      // Otherwise continue as usual
-      hasFields = true;
-      data[fieldAlias] = dataFieldValue;
-    }
-  }
-
-  if (hasPartials) ctx.partial = true;
-  return isQuery && hasPartials && !hasFields ? undefined : data;
-};
-
-const readResolverResult = (
-  ctx: Context,
-  key: string,
-  select: SelectionSet,
-  data: Data,
-  result: Data
-): Data | undefined => {
-  const { store } = ctx;
-  const entityKey = store.keyOfEntity(result) || key;
-  const resolvedTypename = result.__typename;
-  const typename =
-    InMemoryData.readRecord(entityKey, '__typename') || resolvedTypename;
-
-  if (
-    typeof typename !== 'string' ||
-    (resolvedTypename && typename !== resolvedTypename)
-  ) {
-    // TODO: This may be an invalid error for resolvers that return interfaces
-    warn(
-      'Invalid resolver data: The resolver at `' +
-        entityKey +
-        '` returned an ' +
-        'invalid typename that could not be reconciled with the cache.',
-      8
-    );
-
-    return undefined;
-  }
-
-  // The following closely mirrors readSelection, but differs only slightly for the
-  // sake of resolving from an existing resolver result
-  data.__typename = typename;
-  const iter = new SelectionIterator(typename, entityKey, select, ctx);
-
-  let node: FieldNode | void;
-  let hasFields = false;
-  let hasPartials = false;
-  while ((node = iter.next()) !== undefined) {
-    // Derive the needed data from our node.
-    const fieldName = getName(node);
-    const fieldAlias = getFieldAlias(node);
-    const fieldKey = keyOfField(
-      fieldName,
-      getFieldArguments(node, ctx.variables)
-    );
-    const key = joinKeys(entityKey, fieldKey);
-    const fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
-    const resultValue = result[fieldName];
-
-    if (process.env.NODE_ENV !== 'production' && store.schema && typename) {
-      isFieldAvailableOnType(store.schema, typename, fieldName);
-    }
-
-    // We temporarily store the data field in here, but undefined
-    // means that the value is missing from the cache
-    let dataFieldValue: void | DataField;
-    if (resultValue !== undefined && node.selectionSet === undefined) {
-      // The field is a scalar and can be retrieved directly from the result
-      dataFieldValue = resultValue;
     } else if (node.selectionSet === undefined) {
       // The field is a scalar but isn't on the result, so it's retrieved from the cache
       dataFieldValue = fieldValue;
@@ -454,7 +356,7 @@ const readResolverResult = (
     // a partial query result
     if (
       dataFieldValue === undefined &&
-      store.schema !== undefined &&
+      store.schema &&
       isFieldNullable(store.schema, typename, fieldName)
     ) {
       // The field is uncached but we have a schema that says it's nullable
@@ -472,7 +374,7 @@ const readResolverResult = (
   }
 
   if (hasPartials) ctx.partial = true;
-  return !hasFields ? undefined : data;
+  return isQuery && hasPartials && !hasFields ? undefined : data;
 };
 
 const resolveResolverResult = (
@@ -518,7 +420,7 @@ const resolveResolverResult = (
     const data = prevData === undefined ? makeDict() : prevData;
     return typeof result === 'string'
       ? readSelection(ctx, result, select, data)
-      : readResolverResult(ctx, key, select, data, result);
+      : readSelection(ctx, key, select, data, result);
   } else {
     warn(
       'Invalid resolver value: The field at `' +
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index db278565..da7c8f47 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -16,12 +16,50 @@ import { Fragments, Variables, DataField, NullArray, Data } from '../types';
 
 import { getFieldArguments, shouldInclude, isInterfaceOfType } from '../ast';
 
-interface Context {
+export interface Context {
   store: Store;
   variables: Variables;
   fragments: Fragments;
+  parentTypeName: string;
+  parentKey: string;
+  parentFieldKey: string;
+  fieldName: string;
+  partial: boolean;
+  optimistic: boolean;
 }
 
+export const makeContext = (
+  store: Store,
+  variables: Variables,
+  fragments: Fragments,
+  typename: string,
+  entityKey: string,
+  optimistic?: boolean
+): Context => ({
+  store,
+  variables,
+  fragments,
+  parentTypeName: typename,
+  parentKey: entityKey,
+  parentFieldKey: '',
+  fieldName: '',
+  partial: false,
+  optimistic: !!optimistic,
+});
+
+export const updateContext = (
+  ctx: Context,
+  typename: string,
+  entityKey: string,
+  fieldKey: string,
+  fieldName: string
+) => {
+  ctx.parentTypeName = typename;
+  ctx.parentKey = entityKey;
+  ctx.parentFieldKey = fieldKey;
+  ctx.fieldName = fieldName;
+};
+
 const isFragmentHeuristicallyMatching = (
   node: InlineFragmentNode | FragmentDefinitionNode,
   typename: void | string,
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 5d57ebd6..ea8afaa8 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -15,14 +15,7 @@ import {
 
 import { invariant, warn, pushDebugNode } from '../helpers/help';
 
-import {
-  NullArray,
-  Fragments,
-  Variables,
-  Data,
-  Link,
-  OperationRequest,
-} from '../types';
+import { NullArray, Variables, Data, Link, OperationRequest } from '../types';
 
 import {
   Store,
@@ -35,23 +28,18 @@ import {
 
 import * as InMemoryData from '../store/data';
 import { makeDict } from '../helpers/dict';
-import { SelectionIterator, ensureData } from './shared';
+import {
+  Context,
+  SelectionIterator,
+  ensureData,
+  makeContext,
+  updateContext,
+} from './shared';
 
 export interface WriteResult {
   dependencies: Set<string>;
 }
 
-interface Context {
-  parentTypeName: string;
-  parentKey: string;
-  parentFieldKey: string;
-  fieldName: string;
-  store: Store;
-  variables: Variables;
-  fragments: Fragments;
-  optimistic?: boolean;
-}
-
 /** Writes a request given its response to the store */
 export const write = (
   store: Store,
@@ -72,29 +60,21 @@ export const startWrite = (
 ) => {
   const operation = getMainOperation(request.query);
   const result: WriteResult = { dependencies: getCurrentDependencies() };
+  const operationName = store.rootFields[operation.operation];
 
-  const select = getSelectionSet(operation);
-  const operationName = store.getRootKey(operation.operation);
-
-  const ctx: Context = {
-    parentTypeName: operationName,
-    parentKey: operationName,
-    parentFieldKey: '',
-    fieldName: '',
+  const ctx = makeContext(
     store,
-    variables: normalizeVariables(operation, request.variables),
-    fragments: getFragments(request.query),
-  };
+    normalizeVariables(operation, request.variables),
+    getFragments(request.query),
+    operationName,
+    operationName
+  );
 
   if (process.env.NODE_ENV !== 'production') {
     pushDebugNode(operationName, operation);
   }
 
-  if (operationName === ctx.store.getRootKey('query')) {
-    writeSelection(ctx, operationName, select, data);
-  } else {
-    writeRoot(ctx, operationName, select, data);
-  }
+  writeSelection(ctx, operationName, getSelectionSet(operation), data);
 
   return result;
 };
@@ -108,11 +88,10 @@ export const writeOptimistic = (
 
   const operation = getMainOperation(request.query);
   const result: WriteResult = { dependencies: getCurrentDependencies() };
+  const operationName = store.rootFields[operation.operation];
 
-  const mutationRootKey = store.getRootKey('mutation');
-  const operationName = store.getRootKey(operation.operation);
   invariant(
-    operationName === mutationRootKey,
+    operationName === store.rootFields['mutation'],
     'writeOptimistic(...) was called with an operation that is not a mutation.\n' +
       'This case is unsupported and should never occur.',
     10
@@ -122,48 +101,17 @@ export const writeOptimistic = (
     pushDebugNode(operationName, operation);
   }
 
-  const ctx: Context = {
-    parentTypeName: mutationRootKey,
-    parentKey: mutationRootKey,
-    parentFieldKey: '',
-    fieldName: '',
-    variables: normalizeVariables(operation, request.variables),
-    fragments: getFragments(request.query),
+  const ctx = makeContext(
     store,
-    optimistic: true,
-  };
-
-  const data = makeDict();
-  const iter = new SelectionIterator(
+    normalizeVariables(operation, request.variables),
+    getFragments(request.query),
     operationName,
     operationName,
-    getSelectionSet(operation),
-    ctx
+    true
   );
 
-  let node: FieldNode | void;
-  while ((node = iter.next()) !== undefined) {
-    if (node.selectionSet !== undefined) {
-      const fieldName = getName(node);
-      const resolver = ctx.store.optimisticMutations[fieldName];
-
-      if (resolver !== undefined) {
-        // We have to update the context to reflect up-to-date ResolveInfo
-        ctx.fieldName = fieldName;
-
-        const fieldArgs = getFieldArguments(node, ctx.variables);
-        const resolverValue = resolver(fieldArgs || makeDict(), ctx.store, ctx);
-        const resolverData = ensureData(resolverValue);
-        writeRootField(ctx, resolverData, getSelectionSet(node));
-        data[fieldName] = resolverValue;
-        const updater = ctx.store.updates[mutationRootKey][fieldName];
-        if (updater !== undefined) {
-          updater(data, fieldArgs || makeDict(), ctx.store, ctx);
-        }
-      }
-    }
-  }
-
+  const data = makeDict();
+  writeSelection(ctx, operationName, getSelectionSet(operation), data);
   clearDataState();
   return result;
 };
@@ -202,43 +150,48 @@ export const writeFragment = (
     pushDebugNode(typename, fragment);
   }
 
-  const ctx: Context = {
-    parentTypeName: typename,
-    parentKey: entityKey,
-    parentFieldKey: '',
-    fieldName: '',
-    variables: variables || {},
-    fragments,
+  const ctx = makeContext(
     store,
-  };
+    variables || {},
+    fragments,
+    typename,
+    entityKey
+  );
 
   writeSelection(ctx, entityKey, getSelectionSet(fragment), writeData);
 };
 
 const writeSelection = (
   ctx: Context,
-  entityKey: string,
+  entityKey: undefined | string,
   select: SelectionSet,
   data: Data
 ) => {
-  const isQuery = entityKey === ctx.store.getRootKey('query');
-  const typename = isQuery ? entityKey : data.__typename;
-  if (typeof typename !== 'string') return;
-
-  InMemoryData.writeRecord(entityKey, '__typename', typename);
+  const isQuery = entityKey === ctx.store.rootFields['query'];
+  const isRoot = !isQuery && !!ctx.store.rootNames[entityKey!];
+  const typename = isRoot || isQuery ? entityKey : data.__typename;
+  if (!typename) {
+    return;
+  } else if (!isRoot && !isQuery && entityKey) {
+    InMemoryData.writeRecord(entityKey, '__typename', typename);
+  }
 
-  const iter = new SelectionIterator(typename, entityKey, select, ctx);
+  const iter = new SelectionIterator(
+    typename,
+    entityKey || typename,
+    select,
+    ctx
+  );
 
   let node: FieldNode | void;
-  while ((node = iter.next()) !== undefined) {
+  while ((node = iter.next())) {
     const fieldName = getName(node);
     const fieldArgs = getFieldArguments(node, ctx.variables);
     const fieldKey = keyOfField(fieldName, fieldArgs);
     const fieldValue = data[getFieldAlias(node)];
-    const key = joinKeys(entityKey, fieldKey);
 
     if (process.env.NODE_ENV !== 'production') {
-      if (fieldValue === undefined) {
+      if (!isRoot && fieldValue === undefined) {
         const advice = ctx.optimistic
           ? '\nYour optimistic result may be missing a field!'
           : '';
@@ -264,32 +217,72 @@ const writeSelection = (
       }
     }
 
-    if (node.selectionSet === undefined) {
-      // This is a leaf node, so we're setting the field's value directly
-      InMemoryData.writeRecord(entityKey, fieldKey, fieldValue);
-    } else {
+    if (node.selectionSet) {
+      let fieldData: Data | NullArray<Data> | null;
+      // Process optimistic updates, if this is a `writeOptimistic` operation
+      // otherwise read the field value from data and write it
+      if (ctx.optimistic && isRoot) {
+        const resolver = ctx.store.optimisticMutations[fieldName];
+        if (!resolver) continue;
+        // We have to update the context to reflect up-to-date ResolveInfo
+        updateContext(ctx, typename, typename, fieldKey, fieldName);
+        fieldData = ensureData(
+          resolver(fieldArgs || makeDict(), ctx.store, ctx)
+        );
+        data[fieldName] = fieldData;
+      } else {
+        fieldData = ensureData(fieldValue);
+      }
+
       // Process the field and write links for the child entities that have been written
-      const fieldData = ensureData(fieldValue);
-      const link = writeField(ctx, key, getSelectionSet(node), fieldData);
-      InMemoryData.writeLink(entityKey, fieldKey, link);
+      if (entityKey && !isRoot) {
+        const key = joinKeys(entityKey, fieldKey);
+        const link = writeField(ctx, getSelectionSet(node), fieldData, key);
+        InMemoryData.writeLink(entityKey || typename, fieldKey, link);
+      } else {
+        writeField(ctx, getSelectionSet(node), fieldData);
+      }
+    } else if (entityKey && !isRoot) {
+      // This is a leaf node, so we're setting the field's value directly
+      InMemoryData.writeRecord(entityKey || typename, fieldKey, fieldValue);
+    }
+
+    if (isRoot) {
+      // We have to update the context to reflect up-to-date ResolveInfo
+      updateContext(
+        ctx,
+        typename,
+        typename,
+        joinKeys(typename, fieldKey),
+        fieldName
+      );
+
+      // We run side-effect updates after the default, normalized updates
+      // so that the data is already available in-store if necessary
+      const updater = ctx.store.updates[typename][fieldName];
+      if (updater) {
+        updater(data, fieldArgs || makeDict(), ctx.store, ctx);
+      }
     }
   }
 };
 
 const writeField = (
   ctx: Context,
-  parentFieldKey: string,
   select: SelectionSet,
-  data: null | Data | NullArray<Data>
+  data: null | Data | NullArray<Data>,
+  parentFieldKey?: string
 ): Link => {
   if (Array.isArray(data)) {
     const newData = new Array(data.length);
     for (let i = 0, l = data.length; i < l; i++) {
       const item = data[i];
       // Append the current index to the parentFieldKey fallback
-      const indexKey = joinKeys(parentFieldKey, `${i}`);
+      const indexKey = parentFieldKey
+        ? joinKeys(parentFieldKey, `${i}`)
+        : undefined;
       // Recursively write array data
-      const links = writeField(ctx, indexKey, select, item);
+      const links = writeField(ctx, select, item, indexKey);
       // Link cannot be expressed as a recursive type
       newData[i] = links as string | null;
     }
@@ -300,10 +293,10 @@ const writeField = (
   }
 
   const entityKey = ctx.store.keyOfEntity(data);
-  const key = entityKey !== null ? entityKey : parentFieldKey;
   const typename = data.__typename;
 
   if (
+    parentFieldKey &&
     ctx.store.keys[data.__typename] === undefined &&
     entityKey === null &&
     typeof typename === 'string' &&
@@ -328,71 +321,7 @@ const writeField = (
     );
   }
 
-  writeSelection(ctx, key, select, data);
-  return key;
-};
-
-// This is like writeSelection but assumes no parent entity exists
-const writeRoot = (
-  ctx: Context,
-  typename: string,
-  select: SelectionSet,
-  data: Data
-) => {
-  const isRootField =
-    typename === ctx.store.getRootKey('mutation') ||
-    typename === ctx.store.getRootKey('subscription');
-
-  const iter = new SelectionIterator(typename, typename, select, ctx);
-
-  let node: FieldNode | void;
-  while ((node = iter.next()) !== undefined) {
-    const fieldName = getName(node);
-    const fieldArgs = getFieldArguments(node, ctx.variables);
-    const fieldKey = joinKeys(typename, keyOfField(fieldName, fieldArgs));
-    if (node.selectionSet !== undefined) {
-      const fieldValue = ensureData(data[getFieldAlias(node)]);
-      writeRootField(ctx, fieldValue, getSelectionSet(node));
-    }
-
-    if (isRootField) {
-      // We have to update the context to reflect up-to-date ResolveInfo
-      ctx.parentTypeName = typename;
-      ctx.parentKey = typename;
-      ctx.parentFieldKey = fieldKey;
-      ctx.fieldName = fieldName;
-
-      // We run side-effect updates after the default, normalized updates
-      // so that the data is already available in-store if necessary
-      const updater = ctx.store.updates[typename][fieldName];
-      if (updater !== undefined) {
-        updater(data, fieldArgs || makeDict(), ctx.store, ctx);
-      }
-    }
-  }
-};
-
-// This is like writeField but doesn't fall back to a generated key
-const writeRootField = (
-  ctx: Context,
-  data: null | Data | NullArray<Data>,
-  select: SelectionSet
-) => {
-  if (Array.isArray(data)) {
-    const newData = new Array(data.length);
-    for (let i = 0, l = data.length; i < l; i++)
-      newData[i] = writeRootField(ctx, data[i], select);
-    return newData;
-  } else if (data === null) {
-    return;
-  }
-
-  // Write entity to key that falls back to the given parentFieldKey
-  const entityKey = ctx.store.keyOfEntity(data);
-  if (entityKey !== null) {
-    writeSelection(ctx, entityKey, select, data);
-  } else {
-    const typename = data.__typename;
-    writeRoot(ctx, typename, select, data);
-  }
+  const childKey = entityKey || parentFieldKey;
+  writeSelection(ctx, childKey, select, data);
+  return childKey || null;
 };
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 66644a8a..3dea12d5 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -49,6 +49,7 @@ export interface InMemoryData {
 
 let currentData: null | InMemoryData = null;
 let currentDependencies: null | Set<string> = null;
+let previousDependencies: null | Set<string> = null;
 let currentOptimisticKey: null | number = null;
 
 const makeNodeMap = <T>(): NodeMap<T> => ({
@@ -63,6 +64,7 @@ export const initDataState = (
   isOptimistic?: boolean
 ) => {
   currentData = data;
+  previousDependencies = currentDependencies;
   currentDependencies = new Set();
   if (process.env.NODE_ENV !== 'production') {
     currentDebugStack.length = 0;
@@ -103,16 +105,11 @@ export const clearDataState = () => {
 
   // Determine whether the current operation has been a commutative layer
   if (layerKey && data.optimisticOrder.indexOf(layerKey) > -1) {
-    // Find the lowest index of the commutative layers
-    // The first part of `optimisticOrder` are the non-commutative layers
-    const commutativeIndex =
-      data.optimisticOrder.length - data.commutativeKeys.size;
-
     // Squash all layers in reverse order (low priority upwards) that have
     // been written already
     let i = data.optimisticOrder.length;
     while (
-      --i >= commutativeIndex &&
+      --i >= 0 &&
       data.refLock[data.optimisticOrder[i]] &&
       data.commutativeKeys.has(data.optimisticOrder[i])
     ) {
@@ -166,6 +163,16 @@ export const getCurrentDependencies = (): Set<string> => {
   return currentDependencies;
 };
 
+export const forkDependencies = (): Set<string> => {
+  previousDependencies = currentDependencies;
+  return (currentDependencies = new Set());
+};
+
+export const unforkDependencies = () => {
+  currentDependencies = previousDependencies;
+  previousDependencies = null;
+};
+
 export const make = (queryRootKey: string): InMemoryData => ({
   persistenceScheduled: false,
   persistenceBatch: makeDict(),
@@ -404,7 +411,7 @@ export const readLink = (
 export const writeRecord = (
   entityKey: string,
   fieldKey: string,
-  value: EntityField
+  value?: EntityField
 ) => {
   updateDependencies(entityKey, fieldKey);
   setNode(currentData!.records, entityKey, fieldKey, value);
@@ -422,7 +429,7 @@ export const hasField = (entityKey: string, fieldKey: string): boolean =>
 export const writeLink = (
   entityKey: string,
   fieldKey: string,
-  link: Link | undefined
+  link?: Link | undefined
 ) => {
   const data = currentData!;
   // Retrieve the reference counting dict or the optimistic reference locking dict
@@ -449,8 +456,8 @@ export const writeLink = (
   }
 
   // Retrieve the previous link for this field
-  const prevLinkNode = links !== undefined ? links.get(entityKey) : undefined;
-  const prevLink = prevLinkNode !== undefined ? prevLinkNode[fieldKey] : null;
+  const prevLinkNode = links && links.get(entityKey);
+  const prevLink = prevLinkNode && prevLinkNode[fieldKey];
 
   // Update dependencies
   updateDependencies(entityKey, fieldKey);
@@ -508,8 +515,7 @@ const deleteLayer = (data: InMemoryData, layerKey: number) => {
 /** Merges an optimistic layer of links and records into the base data */
 const squashLayer = (layerKey: number) => {
   // Hide current dependencies from squashing operations
-  const prevDependencies = currentDependencies;
-  currentDependencies = new Set();
+  forkDependencies();
 
   const links = currentData!.links.optimistic[layerKey];
   if (links) {
@@ -527,7 +533,7 @@ const squashLayer = (layerKey: number) => {
     });
   }
 
-  currentDependencies = prevDependencies;
+  unforkDependencies();
   deleteLayer(currentData!, layerKey);
 };
 
diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts
index 94866520..f335b6b8 100644
--- a/exchanges/graphcache/src/store/store.ts
+++ b/exchanges/graphcache/src/store/store.ts
@@ -58,59 +58,36 @@ export class Store implements Cache {
       Subscription: (updates && updates.Subscription) || {},
     } as UpdatesConfig;
 
+    let queryName = 'Query';
+    let mutationName = 'Mutation';
+    let subscriptionName = 'Subscription';
     if (rawSchema) {
       const schema = (this.schema = buildClientSchema(rawSchema));
-
       const queryType = schema.getQueryType();
       const mutationType = schema.getMutationType();
       const subscriptionType = schema.getSubscriptionType();
-
-      const queryName = queryType ? queryType.name : 'Query';
-      const mutationName = mutationType ? mutationType.name : 'Mutation';
-      const subscriptionName = subscriptionType
-        ? subscriptionType.name
-        : 'Subscription';
-
-      this.rootFields = {
-        query: queryName,
-        mutation: mutationName,
-        subscription: subscriptionName,
-      };
-
-      this.rootNames = {
-        [queryName]: 'query',
-        [mutationName]: 'mutation',
-        [subscriptionName]: 'subscription',
-      };
-    } else {
-      this.rootFields = {
-        query: 'Query',
-        mutation: 'Mutation',
-        subscription: 'Subscription',
-      };
-
-      this.rootNames = {
-        Query: 'query',
-        Mutation: 'mutation',
-        Subscription: 'subscription',
-      };
+      if (queryType) queryName = queryType.name;
+      if (mutationType) mutationName = mutationType.name;
+      if (subscriptionType) subscriptionName = subscriptionType.name;
     }
 
-    this.data = InMemoryData.make(this.getRootKey('query'));
-  }
+    this.rootFields = {
+      query: queryName,
+      mutation: mutationName,
+      subscription: subscriptionName,
+    };
 
-  gcScheduled = false;
-  gc = () => {
-    InMemoryData.gc(this.data);
-    this.gcScheduled = false;
-  };
+    this.rootNames = {
+      [queryName]: 'query',
+      [mutationName]: 'mutation',
+      [subscriptionName]: 'subscription',
+    };
 
-  keyOfField = keyOfField;
-
-  getRootKey(name: RootField) {
-    return this.rootFields[name];
+    this.data = InMemoryData.make(queryName);
   }
 
+  keyOfField = keyOfField;
+
   keyOfEntity(data: Data) {
     const { __typename: typename, id, _id } = data;
     if (!typename) {
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index 72e93a16..e924db56 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -90,7 +90,7 @@ export interface Cache {
   /** inspectFields() retrieves all known fields for a given entity */
   inspectFields(entity: Data | string | null): FieldInfo[];
 
-  /** invalidateQuery() invalidates all data of a given query */
+  /** @deprecated Use invalidate() instead */
   invalidateQuery(query: DocumentNode, variables?: Variables): void;
 
   /** invalidate() invalidates an entity */
diff --git a/package.json b/package.json
index 0a65ab71..1ff9b170 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,7 @@
     "rollup-plugin-generate-package-json": "^3.2.0",
     "rollup-plugin-terser": "^5.3.0",
     "rollup-plugin-typescript2": "^0.26.0",
+    "rollup-plugin-visualizer": "^3.3.1",
     "terser": "^4.6.6",
     "ts-jest": "^25.2.1",
     "typescript": "^3.8.3"
diff --git a/scripts/rollup/plugins.js b/scripts/rollup/plugins.js
index 22db1216..1e118ed3 100644
--- a/scripts/rollup/plugins.js
+++ b/scripts/rollup/plugins.js
@@ -1,3 +1,5 @@
+import * as path from 'path';
+
 import commonjs from '@rollup/plugin-commonjs';
 import resolve from '@rollup/plugin-node-resolve';
 import typescript from 'rollup-plugin-typescript2';
@@ -6,6 +8,7 @@ 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 babelPluginTransformPipe from '../babel/transform-pipe';
@@ -92,7 +95,11 @@ export const makePlugins = ({ isProduction } = {}) => [
     formatting: 'PRETTY_PRINT',
     compilation_level: 'SIMPLE_OPTIMIZATIONS'
   }),
-  isProduction ? terserMinified : terserPretty
+  isProduction ? terserMinified : terserPretty,
+  isProduction && settings.isAnalyze && visualizer({
+    filename: path.resolve(settings.cwd, 'node_modules/.cache/analyze.html'),
+    sourcemap: true,
+  }),
 ].filter(Boolean);
 
 const terserPretty = terser({
diff --git a/scripts/rollup/settings.js b/scripts/rollup/settings.js
index ad0660d5..8fd5c181 100644
--- a/scripts/rollup/settings.js
+++ b/scripts/rollup/settings.js
@@ -48,3 +48,4 @@ export const hasPreact = externalModules.includes('preact');
 export const hasSvelte = externalModules.includes('svelte');
 export const mayReexport = hasReact || hasPreact || hasSvelte;
 export const isCI = !!process.env.CIRCLECI;
+export const isAnalyze = !!process.env.ANALYZE;
diff --git a/yarn.lock b/yarn.lock
index ac98500f..5938b933 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4931,6 +4931,11 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3:
     d "^1.0.1"
     ext "^1.1.2"
 
+escape-goat@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
+  integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
+
 escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@@ -8539,6 +8544,11 @@ nan@^2.12.1:
   version "2.14.0"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
 
+nanoid@^2.1.6:
+  version "2.1.11"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
+  integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
+
 nanomatch@^1.2.9:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -8991,6 +9001,13 @@ onetime@^5.1.0:
   dependencies:
     mimic-fn "^2.1.0"
 
+open@^6.0.0:
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9"
+  integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==
+  dependencies:
+    is-wsl "^1.1.0"
+
 opencollective-postinstall@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89"
@@ -10309,6 +10326,13 @@ punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
 
+pupa@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
+  integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
+  dependencies:
+    escape-goat "^2.0.0"
+
 q@^1.1.2:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -11251,6 +11275,18 @@ rollup-plugin-typescript2@^0.26.0:
     rollup-pluginutils "2.8.2"
     tslib "1.10.0"
 
+rollup-plugin-visualizer@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-3.3.1.tgz#fac77899559098d28b1b02d90243e683847f6994"
+  integrity sha512-ElVm75615Qr7fGLnlyYmdejffK8neMkbLMEUHfTYW04RhbiH8brgO14jBKiZBCykOBSnPQ7EvNHGBGF/CT/QRg==
+  dependencies:
+    mkdirp "^0.5.1"
+    nanoid "^2.1.6"
+    open "^6.0.0"
+    pupa "^2.0.0"
+    source-map "^0.7.3"
+    yargs "^15.0.0"
+
 rollup-pluginutils@2.8.2, rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2:
   version "2.8.2"
   resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"


Test patch:
diff --git a/exchanges/graphcache/src/ast/node.test.ts b/exchanges/graphcache/src/ast/node.test.ts
deleted file mode 100644
index eeef258e..00000000
--- a/exchanges/graphcache/src/ast/node.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { unwrapType } from './node';
-import { GraphQLScalarType, GraphQLNonNull, GraphQLList } from 'graphql';
-
-describe('unwrapType', () => {
-  it('Should return the type if not wrapped', () => {
-    const type = new GraphQLScalarType({
-      name: 'String',
-      serialize: () => null,
-    });
-
-    expect(unwrapType(type)).toBe(type);
-  });
-
-  it('Should unwrap non-nullable types', () => {
-    const scalar = new GraphQLScalarType({
-      name: 'MyScalar',
-      serialize: () => null,
-    });
-    const type = new GraphQLNonNull(scalar);
-
-    expect(unwrapType(type)).toBe(scalar);
-  });
-
-  it('Should unwrap list types', () => {
-    const scalar = new GraphQLScalarType({
-      name: 'MyScalar',
-      serialize: () => null,
-    });
-    const type = new GraphQLList(scalar);
-
-    expect(unwrapType(type)).toBe(scalar);
-  });
-
-  it('Should unwrap nested types', () => {
-    const scalar = new GraphQLScalarType({
-      name: 'MyScalar',
-      serialize: () => null,
-    });
-
-    const nonNullList = new GraphQLNonNull(scalar);
-
-    const type = new GraphQLList(nonNullList);
-
-    expect(unwrapType(type)).toBe(scalar);
-  });
-});
diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
index 60a8cb57..7efbf046 100644
--- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
+++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
@@ -6,7 +6,6 @@ Object {
   "r|Appointment:1.__typename": "Appointment",
   "r|Appointment:1.id": "1",
   "r|Appointment:1.info": "urql meeting",
-  "r|Query.__typename": "Query",
 }
 `;
 
@@ -16,6 +15,5 @@ Object {
   "r|Appointment:1.__typename": undefined,
   "r|Appointment:1.id": undefined,
   "r|Appointment:1.info": undefined,
-  "r|Query.__typename": "Query",
 }
 `;