Repository: apollographql/apollo-client

Problem statement:
v3.0: loading: true for first render always, even if all data is available in cache
<!--
  Thanks for filing an issue on Apollo Client!

  Please make sure that you include the following information to ensure that your issue is actionable.

  If you don't follow the template, your issue may end up being closed without anyone looking at it carefully, because it is not actionable for us without the information in this template.

  **PLEASE NOTE:** Feature requests and non-bug related discussions are no longer managed in this repo. Feature requests should be opened in https://github.com/apollographql/apollo-feature-requests.
-->

**Intended outcome:**
<!--
What you were trying to accomplish when the bug occurred, and as much code as possible related to the source of the problem.
-->
Attempting to upgrade to @apollo/client@3.0 but I found that my application was rendering a lot more often than it was in 2.0. We have several components that are hooked into the same query, they usually only render once if the query has already completed earlier. 

**Actual outcome:**
<!--
A description of what actually happened, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. Please avoid non-specific phrases like “didn’t work” or “broke”.
-->
In 3.0 it appears that every query will have `loading: true` even if the cache data can satisfy the query. It will then immediately re-render with `loading: false`

**How to reproduce the issue:**
<!--
If possible, please create a reproduction using https://github.com/apollographql/react-apollo-error-template and link to it here. If you prefer an in-browser way to create reproduction, try: https://codesandbox.io/s/github/apollographql/react-apollo-error-template

Instructions for how the issue can be reproduced by a maintainer or contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be.
-->
I recreated the 3.0 issue here: https://codesandbox.io/s/serene-yalow-lvfm9 look at the console and you'll observe the behavior.

Compared to 2.0: https://codesandbox.io/s/determined-galois-e1w2w if you look at the console the same query only renders 1 time with `loading: false`


**Versions**
<!--
Run the following command in your project directory, and paste its (automatically copied to clipboard) results here:

`npx envinfo@latest --preset apollo --clipboard`
-->
@apollo/client@3.0.2

v3.0: loading: true for first render always, even if all data is available in cache
<!--
  Thanks for filing an issue on Apollo Client!

  Please make sure that you include the following information to ensure that your issue is actionable.

  If you don't follow the template, your issue may end up being closed without anyone looking at it carefully, because it is not actionable for us without the information in this template.

  **PLEASE NOTE:** Feature requests and non-bug related discussions are no longer managed in this repo. Feature requests should be opened in https://github.com/apollographql/apollo-feature-requests.
-->

**Intended outcome:**
<!--
What you were trying to accomplish when the bug occurred, and as much code as possible related to the source of the problem.
-->
Attempting to upgrade to @apollo/client@3.0 but I found that my application was rendering a lot more often than it was in 2.0. We have several components that are hooked into the same query, they usually only render once if the query has already completed earlier. 

**Actual outcome:**
<!--
A description of what actually happened, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. Please avoid non-specific phrases like “didn’t work” or “broke”.
-->
In 3.0 it appears that every query will have `loading: true` even if the cache data can satisfy the query. It will then immediately re-render with `loading: false`

**How to reproduce the issue:**
<!--
If possible, please create a reproduction using https://github.com/apollographql/react-apollo-error-template and link to it here. If you prefer an in-browser way to create reproduction, try: https://codesandbox.io/s/github/apollographql/react-apollo-error-template

Instructions for how the issue can be reproduced by a maintainer or contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be.
-->
I recreated the 3.0 issue here: https://codesandbox.io/s/serene-yalow-lvfm9 look at the console and you'll observe the behavior.

Compared to 2.0: https://codesandbox.io/s/determined-galois-e1w2w if you look at the console the same query only renders 1 time with `loading: false`


**Versions**
<!--
Run the following command in your project directory, and paste its (automatically copied to clipboard) results here:

`npx envinfo@latest --preset apollo --clipboard`
-->
@apollo/client@3.0.2



Hints:

Patch:
diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts
--- a/src/core/ObservableQuery.ts
+++ b/src/core/ObservableQuery.ts
@@ -18,15 +18,9 @@ import {
   WatchQueryOptions,
   FetchMoreQueryOptions,
   SubscribeToMoreOptions,
-  ErrorPolicy,
 } from './watchQueryOptions';
-import { QueryStoreValue } from './QueryInfo';
 import { Reobserver } from './Reobserver';
-
-export type ApolloCurrentQueryResult<T> = ApolloQueryResult<T> & {
-  error?: ApolloError;
-  partial?: boolean;
-};
+import { QueryInfo } from './QueryInfo';
 
 export interface FetchMoreOptions<
   TData = any,
@@ -45,14 +39,6 @@ export interface UpdateQueryOptions<TVariables> {
   variables?: TVariables;
 }
 
-export const hasError = (
-  storeValue: QueryStoreValue,
-  policy: ErrorPolicy = 'none',
-) => storeValue && (
-  storeValue.networkError ||
-  (policy === 'none' && isNonEmptyArray(storeValue.graphQLErrors))
-);
-
 let warnedAboutUpdateQuery = false;
 
 export class ObservableQuery<
@@ -77,12 +63,15 @@ export class ObservableQuery<
   private lastResult: ApolloQueryResult<TData>;
   private lastResultSnapshot: ApolloQueryResult<TData>;
   private lastError: ApolloError;
+  private queryInfo: QueryInfo;
 
   constructor({
     queryManager,
+    queryInfo,
     options,
   }: {
     queryManager: QueryManager<any>;
+    queryInfo: QueryInfo;
     options: WatchQueryOptions<TVariables>;
   }) {
     super((observer: Observer<ApolloQueryResult<TData>>) =>
@@ -101,6 +90,8 @@ export class ObservableQuery<
 
     // related classes
     this.queryManager = queryManager;
+
+    this.queryInfo = queryInfo;
   }
 
   public result(): Promise<ApolloQueryResult<TData>> {
@@ -134,26 +125,11 @@ export class ObservableQuery<
     });
   }
 
-  public getCurrentResult(): ApolloCurrentQueryResult<TData> {
-    const {
-      lastResult,
-      lastError,
-      options: { fetchPolicy },
-    } = this;
-
-    const isNetworkFetchPolicy =
-      fetchPolicy === 'network-only' ||
-      fetchPolicy === 'no-cache';
-
-    const networkStatus =
-      lastError ? NetworkStatus.error :
-      lastResult ? lastResult.networkStatus :
-      isNetworkFetchPolicy ? NetworkStatus.loading :
-      NetworkStatus.ready;
-
-    const result: ApolloCurrentQueryResult<TData> = {
-      data: !lastError && lastResult && lastResult.data || void 0,
-      error: lastError,
+  public getCurrentResult(): ApolloQueryResult<TData> {
+    const { lastResult, lastError } = this;
+    const networkStatus = this.queryInfo.networkStatus || NetworkStatus.ready;
+    const result: ApolloQueryResult<TData> = {
+      ...(lastError ? { error: lastError } : lastResult),
       loading: isNetworkRequestInFlight(networkStatus),
       networkStatus,
     };
@@ -162,51 +138,40 @@ export class ObservableQuery<
       return result;
     }
 
-    const { data, partial } = this.getCurrentQueryResult();
-    Object.assign(result, { data, partial });
-
-    const queryStoreValue = this.queryManager.getQueryStoreValue(this.queryId);
-    if (queryStoreValue) {
-      const { networkStatus } = queryStoreValue;
-
-      if (hasError(queryStoreValue, this.options.errorPolicy)) {
-        return Object.assign(result, {
-          data: void 0,
-          networkStatus,
-          error: new ApolloError({
-            graphQLErrors: queryStoreValue.graphQLErrors,
-            networkError: queryStoreValue.networkError,
-          }),
-        });
-      }
-
-      // Variables might have been added dynamically at query time, when
-      // using `@client @export(as: "varname")` for example. When this happens,
-      // the variables have been updated in the query store, but not updated on
-      // the original `ObservableQuery`. We'll update the observable query
-      // variables here to match, so retrieving from the cache doesn't fail.
-      if (queryStoreValue.variables) {
-        this.options.variables = {
-          ...this.options.variables,
-          ...(queryStoreValue.variables as TVariables),
-        };
-      }
-
-      Object.assign(result, {
-        loading: isNetworkRequestInFlight(networkStatus),
-        networkStatus,
-      });
-
-      if (queryStoreValue.graphQLErrors && this.options.errorPolicy === 'all') {
-        result.errors = queryStoreValue.graphQLErrors;
+    const { fetchPolicy = 'cache-first' } = this.options;
+    if (fetchPolicy === 'no-cache' ||
+        fetchPolicy === 'network-only') {
+      result.partial = false;
+    } else if (
+      !result.data ||
+      // If this.options.query has @client(always: true) fields, we cannot
+      // trust result.data, since it was read from the cache without
+      // running local resolvers (and it's too late to run resolvers now,
+      // since we must return a result synchronously). TODO In the future
+      // (after Apollo Client 3.0), we should find a way to trust
+      // this.lastResult in more cases, and read from the cache only in
+      // cases when no result has been received yet.
+      !this.queryManager.transform(this.options.query).hasForcedResolvers
+    ) {
+      const diff = this.queryInfo.getDiff();
+      result.partial = !diff.complete;
+      result.data = (
+        diff.complete ||
+        this.options.returnPartialData
+      ) ? diff.result : void 0;
+      // If the cache diff is complete, and we're using a FetchPolicy that
+      // terminates after a complete cache read, we can assume the next
+      // result we receive will have NetworkStatus.ready and !loading.
+      if (diff.complete &&
+          result.networkStatus === NetworkStatus.loading &&
+          (fetchPolicy === 'cache-first' ||
+           fetchPolicy === 'cache-only')) {
+        result.networkStatus = NetworkStatus.ready;
+        result.loading = false;
       }
     }
 
-    if (partial) {
-      this.resetLastResults();
-    } else {
-      this.updateLastResult(result);
-    }
+    this.updateLastResult(result);
 
     return result;
   }
@@ -235,11 +200,7 @@ export class ObservableQuery<
   }
 
   public resetQueryStoreErrors() {
-    const queryStore = this.queryManager.getQueryStoreValue(this.queryId);
-    if (queryStore) {
-      queryStore.networkError = undefined;
-      queryStore.graphQLErrors = [];
-    }
+    this.queryManager.resetErrors(this.queryId);
   }
 
   /**
@@ -307,21 +268,20 @@ export class ObservableQuery<
 
     if (combinedOptions.notifyOnNetworkStatusChange) {
       const currentResult = this.getCurrentResult();
-      const queryInfo = this.queryManager.getQueryStoreValue(this.queryId);
-      if (queryInfo) {
-        // If we neglect to update queryInfo.networkStatus here,
-        // getCurrentResult may return a loading:false result while
-        // fetchMore is in progress, since getCurrentResult also consults
-        // queryInfo.networkStatus. Note: setting queryInfo.networkStatus
-        // to an in-flight status means that QueryInfo#shouldNotify will
-        // return false while fetchMore is in progress, which is why we
-        // call this.reobserve() explicitly in the .finally callback after
-        // fetchMore (below), since the cache write will not automatically
-        // trigger a notification, even though it does trigger a cache
-        // broadcast. This is a good thing, because it means we won't see
-        // intervening query notifications while fetchMore is pending.
-        queryInfo.networkStatus = NetworkStatus.fetchMore;
-      }
+
+      // If we neglect to update queryInfo.networkStatus here,
+      // getCurrentResult may return a loading:false result while
+      // fetchMore is in progress, since getCurrentResult also consults
+      // queryInfo.networkStatus. Note: setting queryInfo.networkStatus
+      // to an in-flight status means that QueryInfo#shouldNotify will
+      // return false while fetchMore is in progress, which is why we
+      // call this.reobserve() explicitly in the .finally callback after
+      // fetchMore (below), since the cache write will not automatically
+      // trigger a notification, even though it does trigger a cache
+      // broadcast. This is a good thing, because it means we won't see
+      // intervening query notifications while fetchMore is pending.
+      this.queryInfo.networkStatus = NetworkStatus.fetchMore;
+
       // Simulate a loading result for the original query with
       // networkStatus === NetworkStatus.fetchMore.
       this.observer.next!({
@@ -508,8 +468,15 @@ once, rather than every time you call fetchMore.`);
     ) => TData,
   ): void {
     const { queryManager } = this;
-    const previousResult = this.getCurrentQueryResult(false).data;
-    const newResult = mapFn(previousResult!, {
+    const { result } = queryManager.cache.diff<TData>({
+      query: this.options.query,
+      variables: this.variables,
+      previousResult: this.lastResult?.data,
+      returnPartialData: true,
+      optimistic: false,
+    });
+
+    const newResult = mapFn(result!, {
       variables: (this as any).variables,
     });
 
@@ -524,49 +491,6 @@ once, rather than every time you call fetchMore.`);
     }
   }
 
-  private getCurrentQueryResult(
-    optimistic: boolean = true,
-  ): {
-    data?: TData;
-    partial: boolean;
-  } {
-    const { fetchPolicy } = this.options;
-    const lastData = this.lastResult?.data;
-    if (fetchPolicy === 'no-cache' ||
-        fetchPolicy === 'network-only') {
-      return {
-        data: lastData,
-        partial: false,
-      };
-    }
-
-    let { result, complete } = this.queryManager.cache.diff<TData>({
-      query: this.options.query,
-      variables: this.variables,
-      previousResult: this.lastResult?.data,
-      returnPartialData: true,
-      optimistic,
-    });
-
-    if (lastData &&
-        !this.lastError &&
-        // If this.options.query has @client(always: true) fields, we
-        // cannot trust result, since it was read from the cache without
-        // running local resolvers (and it's too late to run resolvers
-        // now, since we must return a result synchronously). TODO In the
-        // future (after Apollo Client 3.0), we should find a way to trust
-        // this.lastResult in more cases, and read from the cache only in
-        // cases when no result has been received yet.
-        this.queryManager.transform(this.options.query).hasForcedResolvers) {
-      result = lastData;
-    }
-
-    return {
-      data: (complete || this.options.returnPartialData) ? result : void 0,
-      partial: !complete,
-    };
-  }
-
   public startPolling(pollInterval: number) {
     this.getReobserver().updateOptions({ pollInterval });
   }
@@ -659,7 +583,8 @@ once, rather than every time you call fetchMore.`);
         );
       },
       // Avoid polling during SSR and when the query is already in flight.
-      !queryManager.ssrMode && (() => !queryManager.checkInFlight(queryId)),
+      !queryManager.ssrMode && (
+        () => !isNetworkRequestInFlight(this.queryInfo.networkStatus))
     );
   }
 
diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts
--- a/src/core/QueryInfo.ts
+++ b/src/core/QueryInfo.ts
@@ -29,7 +29,7 @@ export type QueryStoreValue = Pick<QueryInfo,
 // this.queries Map. QueryInfo objects store the latest results and errors
 // for the given query, and are responsible for reporting those results to
 // the corresponding ObservableQuery, via the QueryInfo.notify method.
-// Results are reported asynchronously whenever setDirty marks the
+// Results are reported asynchronously whenever setDiff marks the
 // QueryInfo object as dirty, though a call to the QueryManager's
 // broadcastQueries method may trigger the notification before it happens
 // automatically. This class used to be a simple interface type without
@@ -65,6 +65,10 @@ export class QueryInfo {
       networkStatus = NetworkStatus.setVariables;
     }
 
+    if (!equal(query.variables, this.variables)) {
+      this.diff = null;
+    }
+
     Object.assign(this, {
       document: query.document,
       variables: query.variables,
@@ -86,25 +90,33 @@ export class QueryInfo {
 
   private dirty: boolean = false;
 
-  public setDirty(): this {
-    if (!this.dirty) {
-      this.dirty = true;
-      if (!this.notifyTimeout) {
-        this.notifyTimeout = setTimeout(() => this.notify(), 0);
-      }
-    }
-    return this;
-  }
-
   private notifyTimeout?: ReturnType<typeof setTimeout>;
 
   private diff: Cache.DiffResult<any> | null = null;
 
+  getDiff(variables = this.variables): Cache.DiffResult<any> {
+    if (this.diff && equal(variables, this.variables)) {
+      return this.diff;
+    }
+
+    this.updateWatch(this.variables = variables);
+
+    return this.diff = this.cache.diff({
+      query: this.document!,
+      variables,
+      returnPartialData: true,
+      optimistic: true,
+    });
+  }
+
   setDiff(diff: Cache.DiffResult<any> | null) {
     const oldDiff = this.diff;
     this.diff = diff;
     if (!this.dirty && diff?.result !== oldDiff?.result) {
-      this.setDirty();
+      this.dirty = true;
+      if (!this.notifyTimeout) {
+        this.notifyTimeout = setTimeout(() => this.notify(), 0);
+      }
     }
   }
 
@@ -121,6 +133,7 @@ export class QueryInfo {
     (this as any).observableQuery = oq;
 
     if (oq) {
+      oq["queryInfo"] = this;
       this.listeners.add(this.oqListener = () => oq.reobserve());
     } else {
       delete this.oqListener;
@@ -163,14 +176,6 @@ export class QueryInfo {
     // QueryInfo.prototype.
     delete this.cancel;
 
-    this.variables =
-    this.networkStatus =
-    this.networkError =
-    this.graphQLErrors =
-    this.lastWatch =
-    this.lastWrittenResult =
-    this.lastWrittenVars = void 0;
-
     const oq = this.observableQuery;
     if (oq) oq.stopPolling();
   }
@@ -181,7 +186,11 @@ export class QueryInfo {
 
   private lastWatch?: Cache.WatchOptions;
 
-  public updateWatch<TVars = Record<string, any>>(variables: TVars): this {
+  private updateWatch(variables = this.variables) {
+    const oq = this.observableQuery;
+    if (oq && oq.options.fetchPolicy === "no-cache") {
+      return;
+    }
     if (!this.lastWatch ||
         this.lastWatch.query !== this.document ||
         !equal(variables, this.lastWatch.variables)) {
@@ -193,7 +202,6 @@ export class QueryInfo {
         callback: diff => this.setDiff(diff),
       });
     }
-    return this;
   }
 
   private lastWrittenResult?: FetchResult<any>;
@@ -286,6 +294,10 @@ export class QueryInfo {
             optimistic: true,
           });
 
+          // Any time we're about to update this.diff, we need to make
+          // sure we've started watching the cache.
+          this.updateWatch(options.variables);
+
           // If we're allowed to write to the cache, and we can read a
           // complete result from the cache, update result.data to be the
           // result from the cache, rather than the raw network result.
diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts
--- a/src/core/QueryManager.ts
+++ b/src/core/QueryManager.ts
@@ -33,7 +33,6 @@ import {
 import { ObservableQuery } from './ObservableQuery';
 import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus';
 import {
-  QueryListener,
   ApolloQueryResult,
   OperationVariables,
   MutationQueryReducer,
@@ -357,8 +356,12 @@ export class QueryManager<TStore> {
     return store;
   }
 
-  public getQueryStoreValue(queryId: string): QueryStoreValue | undefined {
-    return queryId ? this.queries.get(queryId) : undefined;
+  public resetErrors(queryId: string) {
+    const queryInfo = this.queries.get(queryId);
+    if (queryInfo) {
+      queryInfo.networkError = undefined;
+      queryInfo.graphQLErrors = [];
+    }
   }
 
   private transformCache = new (canUseWeakMap ? WeakMap : Map)<
@@ -440,12 +443,16 @@ export class QueryManager<TStore> {
       options.notifyOnNetworkStatusChange = false;
     }
 
+    const queryInfo = new QueryInfo(this.cache);
     const observable = new ObservableQuery<T, TVariables>({
       queryManager: this,
+      queryInfo,
       options,
     });
 
-    this.getQuery(observable.queryId).init({
+    this.queries.set(observable.queryId, queryInfo);
+
+    queryInfo.init({
       document: options.query,
       observableQuery: observable,
       variables: options.variables,
@@ -510,10 +517,6 @@ export class QueryManager<TStore> {
     if (queryInfo) queryInfo.stop();
   }
 
-  public addQueryListener(queryId: string, listener: QueryListener) {
-    this.getQuery(queryId).listeners.add(listener);
-  }
-
   public clearStore(): Promise<void> {
     // Before we have sent the reset action to the store, we can no longer
     // rely on the results returned by in-flight requests since these may
@@ -833,24 +836,6 @@ export class QueryManager<TStore> {
       context = {},
     } = options;
 
-    if (fetchPolicy === "cache-and-network" ||
-        fetchPolicy === "network-only") {
-      // When someone chooses cache-and-network or network-only as their
-      // initial FetchPolicy, they almost certainly do not want future cache
-      // updates to trigger unconditional network requests, which is what
-      // repeatedly applying the cache-and-network or network-only policies
-      // would seem to require. Instead, when the cache reports an update
-      // after the initial network request, subsequent network requests should
-      // be triggered only if the cache result is incomplete. This behavior
-      // corresponds exactly to switching to a cache-first FetchPolicy, so we
-      // modify options.fetchPolicy here for the next fetchQueryObservable
-      // call, using the same options object that the Reobserver always passes
-      // to fetchQueryObservable. Note: if these FetchPolicy transitions get
-      // much more complicated, we might consider using some sort of state
-      // machine to capture the transition rules.
-      options.fetchPolicy = "cache-first";
-    }
-
     const mightUseNetwork =
       fetchPolicy === "cache-first" ||
       fetchPolicy === "cache-and-network" ||
@@ -921,7 +906,27 @@ export class QueryManager<TStore> {
         : fromVariables(normalized.variables!)
     );
 
-    concast.cleanup(() => this.fetchCancelFns.delete(queryId));
+    concast.cleanup(() => {
+      this.fetchCancelFns.delete(queryId);
+
+      if (fetchPolicy === "cache-and-network" ||
+          fetchPolicy === "network-only") {
+        // When someone chooses cache-and-network or network-only as their
+        // initial FetchPolicy, they almost certainly do not want future cache
+        // updates to trigger unconditional network requests, which is what
+        // repeatedly applying the cache-and-network or network-only policies
+        // would seem to require. Instead, when the cache reports an update
+        // after the initial network request, subsequent network requests should
+        // be triggered only if the cache result is incomplete. This behavior
+        // corresponds exactly to switching to a cache-first FetchPolicy, so we
+        // modify options.fetchPolicy here for the next fetchQueryObservable
+        // call, using the same options object that the Reobserver always passes
+        // to fetchQueryObservable. Note: if these FetchPolicy transitions get
+        // much more complicated, we might consider using some sort of state
+        // machine to capture the transition rules.
+        options.fetchPolicy = "cache-first";
+      }
+    });
 
     return concast;
   }
@@ -948,15 +953,10 @@ export class QueryManager<TStore> {
       variables,
       lastRequestId: this.generateRequestId(),
       networkStatus,
-    }).updateWatch(variables);
-
-    const readCache = () => this.cache.diff<any>({
-      query,
-      variables,
-      returnPartialData: true,
-      optimistic: true,
     });
 
+    const readCache = () => queryInfo.getDiff(variables);
+
     const resultsFromCache = (
       diff: Cache.DiffResult<TData>,
       networkStatus = queryInfo.networkStatus || NetworkStatus.loading,
@@ -975,6 +975,7 @@ export class QueryManager<TStore> {
         data,
         loading: isNetworkRequestInFlight(networkStatus),
         networkStatus,
+        ...(diff.complete ? null : { partial: true }),
       } as ApolloQueryResult<TData>);
 
       if (this.transform(query).hasForcedResolvers) {
@@ -1065,16 +1066,6 @@ export class QueryManager<TStore> {
       clientAwareness: this.clientAwareness,
     };
   }
-
-  public checkInFlight(queryId: string): boolean {
-    const query = this.getQueryStoreValue(queryId);
-    return (
-      !!query &&
-      !!query.networkStatus &&
-      query.networkStatus !== NetworkStatus.ready &&
-      query.networkStatus !== NetworkStatus.error
-    );
-  }
 }
 
 function markMutationResult<TStore, TData>(
diff --git a/src/core/index.ts b/src/core/index.ts
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -9,7 +9,6 @@ export {
   ObservableQuery,
   FetchMoreOptions,
   UpdateQueryOptions,
-  ApolloCurrentQueryResult,
 } from './ObservableQuery';
 export {
   QueryBaseOptions,
diff --git a/src/core/types.ts b/src/core/types.ts
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,6 +1,7 @@
 import { DocumentNode, GraphQLError } from 'graphql';
 
 import { FetchResult } from '../link/core';
+import { ApolloError } from '../errors';
 import { QueryInfo } from './QueryInfo';
 import { NetworkStatus } from './networkStatus';
 import { Resolver } from './LocalState';
@@ -18,8 +19,13 @@ export type PureQueryOptions = {
 export type ApolloQueryResult<T> = {
   data?: T;
   errors?: ReadonlyArray<GraphQLError>;
+  error?: ApolloError;
   loading: boolean;
   networkStatus: NetworkStatus;
+  // If result.data was read from the cache with missing fields,
+  // result.partial will be true. Otherwise, result.partial will be falsy
+  // (usually because the property is absent from the result object).
+  partial?: boolean;
 };
 
 // This is part of the public API, people write these functions in `updateQueries`.


Test patch:
diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts
--- a/src/core/__tests__/ObservableQuery.ts
+++ b/src/core/__tests__/ObservableQuery.ts
@@ -1176,6 +1176,7 @@ describe('ObservableQuery', () => {
               },
               loading: true,
               networkStatus: NetworkStatus.loading,
+              partial: true,
             });
           } else if (handleCount === 2) {
             expect(result).toEqual({
@@ -1395,8 +1396,8 @@ describe('ObservableQuery', () => {
         });
         expect(stripSymbols(observable.getCurrentResult())).toEqual({
           data: dataOne,
-          loading: true,
-          networkStatus: NetworkStatus.loading,
+          loading: false,
+          networkStatus: NetworkStatus.ready,
           partial: false,
         });
       }).then(resolve, reject);
@@ -1597,6 +1598,7 @@ describe('ObservableQuery', () => {
               data: dataOne,
               loading: true,
               networkStatus: 1,
+              partial: true,
             });
 
           } else if (handleCount === 2) {
@@ -1885,11 +1887,11 @@ describe('ObservableQuery', () => {
       observable.subscribe({
         error() {
           const { queryManager } = (observable as any);
-          const queryStore = queryManager.getQueryStoreValue(observable.queryId);
-          expect(queryStore.graphQLErrors).toEqual([graphQLError]);
+          const queryInfo = queryManager["queries"].get(observable.queryId);
+          expect(queryInfo.graphQLErrors).toEqual([graphQLError]);
 
           observable.resetQueryStoreErrors();
-          expect(queryStore.graphQLErrors).toEqual([]);
+          expect(queryInfo.graphQLErrors).toEqual([]);
 
           resolve();
         }
@@ -1907,10 +1909,10 @@ describe('ObservableQuery', () => {
       observable.subscribe({
         next() {
           const { queryManager } = (observable as any);
-          const queryStore = queryManager.getQueryStoreValue(observable.queryId);
-          queryStore.networkError = networkError;
+          const queryInfo = queryManager["queries"].get(observable.queryId);
+          queryInfo.networkError = networkError;
           observable.resetQueryStoreErrors();
-          expect(queryStore.networkError).toBeUndefined();
+          expect(queryInfo.networkError).toBeUndefined();
           resolve();
         }
       });
diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts
--- a/src/core/__tests__/QueryManager/index.ts
+++ b/src/core/__tests__/QueryManager/index.ts
@@ -191,8 +191,15 @@ describe('QueryManager', () => {
 
   function getCurrentQueryResult<TData, TVars>(
     observableQuery: ObservableQuery<TData, TVars>,
-  ): ReturnType<ObservableQuery<TData, TVars>["getCurrentQueryResult"]> {
-    return (observableQuery as any).getCurrentQueryResult();
+  ): {
+    data?: TData;
+    partial: boolean;
+  } {
+    const result = observableQuery.getCurrentResult();
+    return {
+      data: result.data,
+      partial: !!result.partial,
+    };
   }
 
   itAsync('handles GraphQL errors', (resolve, reject) => {
@@ -2128,6 +2135,7 @@ describe('QueryManager', () => {
             data: {},
             loading: true,
             networkStatus: NetworkStatus.loading,
+            partial: true,
           });
         },
         result => {
@@ -2202,6 +2210,7 @@ describe('QueryManager', () => {
           loading: true,
           networkStatus: NetworkStatus.loading,
           data: {},
+          partial: true,
         });
       } else if (count === 2) {
         expect(result).toEqual({
@@ -2220,6 +2229,7 @@ describe('QueryManager', () => {
           data: {
             info: {},
           },
+          partial: true,
         });
       } else if (count === 4) {
         expect(result).toEqual({
@@ -2243,6 +2253,7 @@ describe('QueryManager', () => {
           loading: true,
           networkStatus: NetworkStatus.loading,
           data: {},
+          partial: true,
         });
       } else if (count === 2) {
         expect(result).toEqual({
diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts
--- a/src/core/__tests__/fetchPolicies.ts
+++ b/src/core/__tests__/fetchPolicies.ts
@@ -494,6 +494,7 @@ describe('cache-and-network', function() {
           data: {},
           loading: true,
           networkStatus: NetworkStatus.setVariables,
+          partial: true,
         });
       } else if (count === 3) {
         expect(result).toEqual({
@@ -520,6 +521,7 @@ describe('cache-and-network', function() {
           data: {},
           loading: true,
           networkStatus: NetworkStatus.setVariables,
+          partial: true,
         });
       } else if (count === 7) {
         expect(result).toEqual({
diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx
--- a/src/react/components/__tests__/client/Query.test.tsx
+++ b/src/react/components/__tests__/client/Query.test.tsx
@@ -1674,9 +1674,6 @@ describe('Query component', () => {
               });
               break;
             case 4:
-              expect(props.loading).toBeTruthy();
-              break;
-            case 5:
               // Good result should be received without any errors.
               expect(props.error).toBeFalsy();
               expect(props.data.allPeople).toBeTruthy();
@@ -1698,7 +1695,7 @@ describe('Query component', () => {
         </Query>
       );
 
-      return wait(() => expect(count).toBe(6)).then(resolve, reject);
+      return wait(() => expect(count).toBe(5)).then(resolve, reject);
     }
   );
 
diff --git a/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx b/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx
--- a/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx
+++ b/src/react/hoc/__tests__/mutations/recycled-queries.test.tsx
@@ -162,9 +162,6 @@ describe('graphql(mutation) update queries', () => {
               });
               break;
             case 3:
-              expect(this.props.data!.loading).toBeTruthy();
-              break;
-            case 4:
               expect(stripSymbols(this.props.data!.todo_list)).toEqual({
                 id: '123',
                 title: 'how to apollo',
@@ -228,7 +225,6 @@ describe('graphql(mutation) update queries', () => {
               expect(todoUpdateQueryCount).toBe(2);
               expect(queryMountCount).toBe(2);
               expect(queryUnmountCount).toBe(2);
-              expect(queryRenderCount).toBe(5);
             }, 5);
           }, 5);
         }, 5);
@@ -236,7 +232,7 @@ describe('graphql(mutation) update queries', () => {
     }, 5);
 
     return wait(() => {
-      expect(queryRenderCount).toBe(5);
+      expect(queryRenderCount).toBe(4);
     });
   });
 
@@ -359,12 +355,6 @@ describe('graphql(mutation) update queries', () => {
               );
               break;
             case 3:
-              expect(this.props.data!.loading).toBeTruthy();
-              expect(stripSymbols(this.props.data!.todo_list)).toEqual(
-                updatedData.todo_list
-              );
-              break;
-            case 4:
               expect(this.props.data!.loading).toBeFalsy();
               expect(stripSymbols(this.props.data!.todo_list)).toEqual(
                 updatedData.todo_list
@@ -405,7 +395,7 @@ describe('graphql(mutation) update queries', () => {
     });
 
     return wait(() => {
-      expect(queryRenderCount).toBe(5);
+      expect(queryRenderCount).toBe(4);
     });
   });
 });
diff --git a/src/react/hoc/__tests__/queries/observableQuery.test.tsx b/src/react/hoc/__tests__/queries/observableQuery.test.tsx
--- a/src/react/hoc/__tests__/queries/observableQuery.test.tsx
+++ b/src/react/hoc/__tests__/queries/observableQuery.test.tsx
@@ -321,9 +321,6 @@ describe('[queries] observableQuery', () => {
               expect(stripSymbols(allPeople)).toEqual(data.allPeople);
               break;
             case 3:
-              expect(loading).toBe(true);
-              break;
-            case 4:
               expect(loading).toBe(false);
               expect(stripSymbols(allPeople)).toEqual(data.allPeople);
               break;
diff --git a/src/react/hoc/__tests__/queries/skip.test.tsx b/src/react/hoc/__tests__/queries/skip.test.tsx
--- a/src/react/hoc/__tests__/queries/skip.test.tsx
+++ b/src/react/hoc/__tests__/queries/skip.test.tsx
@@ -619,32 +619,35 @@ describe('[queries] skip', () => {
     })(
       class extends React.Component<any> {
         render() {
-          switch (count) {
-            case 0:
-              expect(this.props.data.loading).toBeTruthy();
+          switch (++count) {
+            case 1:
+              expect(this.props.data.loading).toBe(true);
               expect(ranQuery).toBe(1);
               break;
-            case 1:
-              expect(this.props.data.loading).toBeFalsy();
+            case 2:
+              expect(this.props.data.loading).toBe(false);
               expect(ranQuery).toBe(1);
               setTimeout(() => {
                 this.props.setSkip(true);
               });
               break;
-            case 2:
+            case 3:
               expect(this.props.data).toBeUndefined();
               expect(ranQuery).toBe(1);
               setTimeout(() => {
                 this.props.setSkip(false);
               });
               break;
-            case 3:
-              expect(this.props.data!.loading).toBeFalsy();
-              expect(ranQuery).toBe(2);
+            case 4:
+              expect(this.props.data!.loading).toBe(true);
+              expect(ranQuery).toBe(3);
+              break;
+            case 5:
+              expect(this.props.data!.loading).toBe(false);
+              expect(ranQuery).toBe(3);
               break;
             default:
           }
-          count += 1;
           return null;
         }
       }
@@ -669,7 +672,7 @@ describe('[queries] skip', () => {
     );
 
     await wait(() => {
-      expect(count).toEqual(4);
+      expect(count).toEqual(5);
     });
   });