Repository: apollographql/apollo-client

Problem statement:
[Data masking] Allow `useFragment` to work with `no-cache` queries
Subject to change based on technical exploration


Hints:
As a starting point, we are going to leave `useFragment` alone because introducing support for `no-cache` would turn it into a "polymorphic" hook which might be confusing.

Instead, we plan to warn when using `no-cache` with data masking and require that you use `@unmask` in order to get the full data result. This option gives us the most flexibility to update this in the future if we find it to be problematic in a non-breaking way.

Patch:
diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts
--- a/src/core/ApolloClient.ts
+++ b/src/core/ApolloClient.ts
@@ -510,6 +510,8 @@ export class ApolloClient<TCacheShape> implements DataProxy {
   >(
     options: SubscriptionOptions<TVariables, T>
   ): Observable<FetchResult<MaybeMasked<T>>> {
+    const id = this.queryManager.generateQueryId();
+
     return this.queryManager
       .startGraphQLSubscription<T>(options)
       .map((result) => ({
@@ -517,6 +519,8 @@ export class ApolloClient<TCacheShape> implements DataProxy {
         data: this.queryManager.maskOperation({
           document: options.query,
           data: result.data,
+          fetchPolicy: options.fetchPolicy,
+          id,
         }),
       }));
   }
diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts
--- a/src/core/ObservableQuery.ts
+++ b/src/core/ObservableQuery.ts
@@ -1140,6 +1140,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
           data: this.queryManager.maskOperation({
             document: this.query,
             data: result.data,
+            fetchPolicy: this.options.fetchPolicy,
+            id: this.queryId,
           }),
         }
       : result;
diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts
--- a/src/core/QueryManager.ts
+++ b/src/core/QueryManager.ts
@@ -13,6 +13,7 @@ import {
   hasDirectives,
   isExecutionPatchIncrementalResult,
   isExecutionPatchResult,
+  isFullyUnmaskedOperation,
   removeDirectivesFromDocument,
 } from "../utilities/index.js";
 import type { Cache, ApolloCache } from "../cache/index.js";
@@ -51,6 +52,7 @@ import type {
   MutationOptions,
   ErrorPolicy,
   MutationFetchPolicy,
+  WatchQueryFetchPolicy,
 } from "./watchQueryOptions.js";
 import { ObservableQuery, logMissingFieldErrors } from "./ObservableQuery.js";
 import { NetworkStatus, isNetworkRequestInFlight } from "./networkStatus.js";
@@ -118,6 +120,8 @@ interface MaskFragmentOptions<TData> {
 interface MaskOperationOptions<TData> {
   document: DocumentNode;
   data: TData;
+  id: string;
+  fetchPolicy?: WatchQueryFetchPolicy;
 }
 
 export interface QueryManagerOptions<TStore> {
@@ -362,6 +366,8 @@ export class QueryManager<TStore> {
               data: self.maskOperation({
                 document: mutation,
                 data: storeResult.data,
+                fetchPolicy,
+                id: mutationId,
               }) as any,
             });
           }
@@ -819,7 +825,12 @@ export class QueryManager<TStore> {
         (result) =>
           result && {
             ...result,
-            data: this.maskOperation({ document: query, data: result.data }),
+            data: this.maskOperation({
+              document: query,
+              data: result.data,
+              fetchPolicy: options.fetchPolicy,
+              id: queryId,
+            }),
           }
       )
       .finally(() => this.stopQuery(queryId));
@@ -1554,11 +1565,34 @@ export class QueryManager<TStore> {
     return results;
   }
 
+  private noCacheWarningsByQueryId = new Set<string>();
+
   public maskOperation<TData = unknown>(
     options: MaskOperationOptions<TData>
   ): MaybeMasked<TData> {
     const { document, data } = options;
 
+    if (__DEV__) {
+      const { fetchPolicy, id } = options;
+      const operationType = getOperationDefinition(document)?.operation;
+      const operationId = (operationType?.[0] ?? "o") + id;
+
+      if (
+        this.dataMasking &&
+        fetchPolicy === "no-cache" &&
+        !isFullyUnmaskedOperation(document) &&
+        !this.noCacheWarningsByQueryId.has(operationId)
+      ) {
+        this.noCacheWarningsByQueryId.add(operationId);
+
+        invariant.warn(
+          '[%s]: Fragments masked by data masking are inaccessible when using fetch policy "no-cache". Please add `@unmask` to each fragment spread to access the data.',
+          getOperationName(document) ??
+            `Unnamed ${operationType ?? "operation"}`
+        );
+      }
+    }
+
     return (
       this.dataMasking ?
         maskOperation(data, document, this.cache)
diff --git a/src/utilities/graphql/fragments.ts b/src/utilities/graphql/fragments.ts
--- a/src/utilities/graphql/fragments.ts
+++ b/src/utilities/graphql/fragments.ts
@@ -1,5 +1,6 @@
 import { invariant, newInvariantError } from "../globals/index.js";
 
+import { BREAK, visit } from "graphql";
 import type {
   DocumentNode,
   FragmentDefinitionNode,
@@ -143,3 +144,21 @@ export function getFragmentFromSelection(
       return null;
   }
 }
+
+export function isFullyUnmaskedOperation(document: DocumentNode) {
+  let isUnmasked = true;
+
+  visit(document, {
+    FragmentSpread: (node) => {
+      isUnmasked =
+        !!node.directives &&
+        node.directives.some((directive) => directive.name.value === "unmask");
+
+      if (!isUnmasked) {
+        return BREAK;
+      }
+    },
+  });
+
+  return isUnmasked;
+}
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -23,6 +23,7 @@ export {
   createFragmentMap,
   getFragmentQueryDocument,
   getFragmentFromSelection,
+  isFullyUnmaskedOperation,
 } from "./graphql/fragments.js";
 
 export {


Test patch:
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap
--- a/src/__tests__/__snapshots__/exports.ts.snap
+++ b/src/__tests__/__snapshots__/exports.ts.snap
@@ -465,6 +465,7 @@ Array [
   "isExecutionPatchInitialResult",
   "isExecutionPatchResult",
   "isField",
+  "isFullyUnmaskedOperation",
   "isInlineFragment",
   "isMutationOperation",
   "isNonEmptyArray",
diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts
--- a/src/__tests__/dataMasking.ts
+++ b/src/__tests__/dataMasking.ts
@@ -26,6 +26,9 @@ import { createFragmentRegistry } from "../cache/inmemory/fragmentRegistry";
 import { isSubscriptionOperation } from "../utilities";
 import { MaskedDocumentNode } from "../masking";
 
+const NO_CACHE_WARNING =
+  '[%s]: Fragments masked by data masking are inaccessible when using fetch policy "no-cache". Please add `@unmask` to each fragment spread to access the data.';
+
 describe("client.watchQuery", () => {
   test("masks queries when dataMasking is `true`", async () => {
     type UserFieldsFragment = {
@@ -2317,221 +2320,504 @@ describe("client.watchQuery", () => {
       },
     });
   });
-});
 
-describe("client.watchFragment", () => {
-  test("masks watched fragments when dataMasking is `true`", async () => {
+  test("warns and returns masked result when used with no-cache fetch policy", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
-      __typename: "User";
-      id: number;
       age: number;
-    } & { " $fragmentName"?: "UserFieldsFragment" } & {
-      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
-    };
+    } & { " $fragmentName"?: "UserFieldsFragment" };
 
-    type NameFieldsFragment = {
-      __typename: "User";
-      firstName: string;
-      lastName: string;
-    } & { " $fragmentName"?: "NameFieldsFragment" };
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
 
-    const nameFieldsFragment: MaskedDocumentNode<NameFieldsFragment> = gql`
-      fragment NameFields on User {
-        firstName
-        lastName
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields
+        }
       }
-    `;
 
-    const userFieldsFragment: MaskedDocumentNode<UserFieldsFragment> = gql`
       fragment UserFields on User {
-        id
         age
-        ...NameFields
       }
-
-      ${nameFieldsFragment}
     `;
 
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
     const client = new ApolloClient({
       dataMasking: true,
       cache: new InMemoryCache(),
+      link: new MockLink(mocks),
     });
 
-    client.writeFragment({
-      fragment: userFieldsFragment,
-      fragmentName: "UserFields",
-      data: {
-        __typename: "User",
-        id: 1,
-        age: 30,
-        firstName: "Test",
-        lastName: "User",
-      },
-    });
-
-    const fragmentStream = new ObservableStream(
-      client.watchFragment({
-        fragment: userFieldsFragment,
-        fragmentName: "UserFields",
-        from: { __typename: "User", id: 1 },
-      })
-    );
-
-    const { data, complete } = await fragmentStream.takeNext();
-
-    expect(data).toEqual({ __typename: "User", id: 1, age: 30 });
-    expect(complete).toBe(true);
-    invariant(complete, "Should never be incomplete");
-
-    const nestedFragmentStream = new ObservableStream(
-      client.watchFragment({ fragment: nameFieldsFragment, from: data })
-    );
+    const observable = client.watchQuery({ query, fetchPolicy: "no-cache" });
+    const stream = new ObservableStream(observable);
 
     {
-      const { data, complete } = await nestedFragmentStream.takeNext();
+      const { data } = await stream.takeNext();
 
-      expect(complete).toBe(true);
       expect(data).toEqual({
-        __typename: "User",
-        firstName: "Test",
-        lastName: "User",
+        currentUser: {
+          __typename: "User",
+          id: 1,
+          name: "Test User",
+        },
       });
     }
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery");
   });
 
-  test("does not mask watched fragments when dataMasking is disabled", async () => {
+  test("does not warn on no-cache queries when data masking is disabled", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
-      __typename: "User";
-      id: number;
       age: number;
-    } & { " $fragmentName"?: "UserFieldsFragment" } & {
-      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
-    };
+    } & { " $fragmentName"?: "UserFieldsFragment" };
 
-    type NameFieldsFragment = {
-      __typename: "User";
-      firstName: string;
-      lastName: string;
-    } & { " $fragmentName"?: "NameFieldsFragment" };
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
 
-    const nameFieldsFragment: TypedDocumentNode<NameFieldsFragment> = gql`
-      fragment NameFields on User {
-        __typename
-        firstName
-        lastName
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields
+        }
       }
-    `;
 
-    const userFieldsFragment: TypedDocumentNode<UserFieldsFragment> = gql`
       fragment UserFields on User {
-        __typename
-        id
         age
-        ...NameFields
       }
-
-      ${nameFieldsFragment}
     `;
 
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
     const client = new ApolloClient({
       dataMasking: false,
       cache: new InMemoryCache(),
+      link: new MockLink(mocks),
     });
 
-    client.writeFragment({
-      fragment: userFieldsFragment,
-      fragmentName: "UserFields",
-      data: {
-        __typename: "User",
-        id: 1,
-        age: 30,
-        firstName: "Test",
-        lastName: "User",
-      },
-    });
-
-    const fragmentStream = new ObservableStream(
-      client.watchFragment({
-        fragment: userFieldsFragment,
-        fragmentName: "UserFields",
-        from: { __typename: "User", id: 1 },
-      })
-    );
-
-    const { data, complete } = await fragmentStream.takeNext();
-
-    expect(data).toEqual({
-      __typename: "User",
-      id: 1,
-      age: 30,
-      firstName: "Test",
-      lastName: "User",
-    });
-    expect(complete).toBe(true);
-    invariant(complete, "Should never be incomplete");
-
-    const nestedFragmentStream = new ObservableStream(
-      client.watchFragment({ fragment: nameFieldsFragment, from: data })
-    );
+    const observable = client.watchQuery({ query, fetchPolicy: "no-cache" });
+    const stream = new ObservableStream(observable);
 
     {
-      const { data, complete } = await nestedFragmentStream.takeNext();
+      const { data } = await stream.takeNext();
 
-      expect(complete).toBe(true);
       expect(data).toEqual({
-        __typename: "User",
-        firstName: "Test",
-        lastName: "User",
+        currentUser: {
+          __typename: "User",
+          id: 1,
+          name: "Test User",
+          age: 30,
+        },
       });
     }
+
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("does not mask watched fragments by default", async () => {
+  test("does not warn on no-cache queries when all fragments use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
-      __typename: "User";
-      id: number;
       age: number;
-    } & { " $fragmentName"?: "UserFieldsFragment" } & {
-      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
-    };
+    } & { " $fragmentName"?: "UserFieldsFragment" };
 
-    type NameFieldsFragment = {
-      __typename: "User";
-      firstName: string;
-      lastName: string;
-    } & { " $fragmentName"?: "NameFieldsFragment" };
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
 
-    const nameFieldsFragment: TypedDocumentNode<NameFieldsFragment> = gql`
-      fragment NameFields on User {
-        __typename
-        firstName
-        lastName
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields @unmask
+        }
       }
-    `;
 
-    const userFieldsFragment: TypedDocumentNode<UserFieldsFragment> = gql`
       fragment UserFields on User {
-        __typename
-        id
         age
-        ...NameFields
       }
-
-      ${nameFieldsFragment}
     `;
 
-    const client = new ApolloClient({
-      cache: new InMemoryCache(),
-    });
-
-    client.writeFragment({
-      fragment: userFieldsFragment,
-      fragmentName: "UserFields",
-      data: {
-        __typename: "User",
-        id: 1,
-        age: 30,
-        firstName: "Test",
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const observable = client.watchQuery({ query, fetchPolicy: "no-cache" });
+    const stream = new ObservableStream(observable);
+
+    {
+      const { data } = await stream.takeNext();
+
+      expect(data).toEqual({
+        currentUser: {
+          __typename: "User",
+          id: 1,
+          name: "Test User",
+          age: 30,
+        },
+      });
+    }
+
+    expect(console.warn).not.toHaveBeenCalled();
+  });
+
+  test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
+
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields @unmask
+        }
+      }
+
+      fragment UserFields on User {
+        age
+        ...ProfileFields
+      }
+
+      fragment ProfileFields on User {
+        username
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+              username: "testuser",
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const observable = client.watchQuery({ query, fetchPolicy: "no-cache" });
+    const stream = new ObservableStream(observable);
+
+    {
+      const { data } = await stream.takeNext();
+
+      expect(data).toEqual({
+        currentUser: {
+          __typename: "User",
+          id: 1,
+          name: "Test User",
+          age: 30,
+        },
+      });
+    }
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery");
+  });
+});
+
+describe("client.watchFragment", () => {
+  test("masks watched fragments when dataMasking is `true`", async () => {
+    type UserFieldsFragment = {
+      __typename: "User";
+      id: number;
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" } & {
+      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
+    };
+
+    type NameFieldsFragment = {
+      __typename: "User";
+      firstName: string;
+      lastName: string;
+    } & { " $fragmentName"?: "NameFieldsFragment" };
+
+    const nameFieldsFragment: MaskedDocumentNode<NameFieldsFragment> = gql`
+      fragment NameFields on User {
+        firstName
+        lastName
+      }
+    `;
+
+    const userFieldsFragment: MaskedDocumentNode<UserFieldsFragment> = gql`
+      fragment UserFields on User {
+        id
+        age
+        ...NameFields
+      }
+
+      ${nameFieldsFragment}
+    `;
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+    });
+
+    client.writeFragment({
+      fragment: userFieldsFragment,
+      fragmentName: "UserFields",
+      data: {
+        __typename: "User",
+        id: 1,
+        age: 30,
+        firstName: "Test",
+        lastName: "User",
+      },
+    });
+
+    const fragmentStream = new ObservableStream(
+      client.watchFragment({
+        fragment: userFieldsFragment,
+        fragmentName: "UserFields",
+        from: { __typename: "User", id: 1 },
+      })
+    );
+
+    const { data, complete } = await fragmentStream.takeNext();
+
+    expect(data).toEqual({ __typename: "User", id: 1, age: 30 });
+    expect(complete).toBe(true);
+    invariant(complete, "Should never be incomplete");
+
+    const nestedFragmentStream = new ObservableStream(
+      client.watchFragment({ fragment: nameFieldsFragment, from: data })
+    );
+
+    {
+      const { data, complete } = await nestedFragmentStream.takeNext();
+
+      expect(complete).toBe(true);
+      expect(data).toEqual({
+        __typename: "User",
+        firstName: "Test",
+        lastName: "User",
+      });
+    }
+  });
+
+  test("does not mask watched fragments when dataMasking is disabled", async () => {
+    type UserFieldsFragment = {
+      __typename: "User";
+      id: number;
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" } & {
+      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
+    };
+
+    type NameFieldsFragment = {
+      __typename: "User";
+      firstName: string;
+      lastName: string;
+    } & { " $fragmentName"?: "NameFieldsFragment" };
+
+    const nameFieldsFragment: TypedDocumentNode<NameFieldsFragment> = gql`
+      fragment NameFields on User {
+        __typename
+        firstName
+        lastName
+      }
+    `;
+
+    const userFieldsFragment: TypedDocumentNode<UserFieldsFragment> = gql`
+      fragment UserFields on User {
+        __typename
+        id
+        age
+        ...NameFields
+      }
+
+      ${nameFieldsFragment}
+    `;
+
+    const client = new ApolloClient({
+      dataMasking: false,
+      cache: new InMemoryCache(),
+    });
+
+    client.writeFragment({
+      fragment: userFieldsFragment,
+      fragmentName: "UserFields",
+      data: {
+        __typename: "User",
+        id: 1,
+        age: 30,
+        firstName: "Test",
+        lastName: "User",
+      },
+    });
+
+    const fragmentStream = new ObservableStream(
+      client.watchFragment({
+        fragment: userFieldsFragment,
+        fragmentName: "UserFields",
+        from: { __typename: "User", id: 1 },
+      })
+    );
+
+    const { data, complete } = await fragmentStream.takeNext();
+
+    expect(data).toEqual({
+      __typename: "User",
+      id: 1,
+      age: 30,
+      firstName: "Test",
+      lastName: "User",
+    });
+    expect(complete).toBe(true);
+    invariant(complete, "Should never be incomplete");
+
+    const nestedFragmentStream = new ObservableStream(
+      client.watchFragment({ fragment: nameFieldsFragment, from: data })
+    );
+
+    {
+      const { data, complete } = await nestedFragmentStream.takeNext();
+
+      expect(complete).toBe(true);
+      expect(data).toEqual({
+        __typename: "User",
+        firstName: "Test",
+        lastName: "User",
+      });
+    }
+  });
+
+  test("does not mask watched fragments by default", async () => {
+    type UserFieldsFragment = {
+      __typename: "User";
+      id: number;
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" } & {
+      " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment };
+    };
+
+    type NameFieldsFragment = {
+      __typename: "User";
+      firstName: string;
+      lastName: string;
+    } & { " $fragmentName"?: "NameFieldsFragment" };
+
+    const nameFieldsFragment: TypedDocumentNode<NameFieldsFragment> = gql`
+      fragment NameFields on User {
+        __typename
+        firstName
+        lastName
+      }
+    `;
+
+    const userFieldsFragment: TypedDocumentNode<UserFieldsFragment> = gql`
+      fragment UserFields on User {
+        __typename
+        id
+        age
+        ...NameFields
+      }
+
+      ${nameFieldsFragment}
+    `;
+
+    const client = new ApolloClient({
+      cache: new InMemoryCache(),
+    });
+
+    client.writeFragment({
+      fragment: userFieldsFragment,
+      fragmentName: "UserFields",
+      data: {
+        __typename: "User",
+        id: 1,
+        age: 30,
+        firstName: "Test",
         lastName: "User",
       },
     });
@@ -3564,27 +3850,288 @@ describe("client.query", () => {
       link: new MockLink(mocks),
     });
 
-    const { data, errors } = await client.query({ query, errorPolicy: "all" });
+    const { data, errors } = await client.query({ query, errorPolicy: "all" });
+
+    expect(data).toEqual({
+      currentUser: null,
+    });
+
+    expect(errors).toEqual([{ message: "User not logged in" }]);
+  });
+
+  test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => {
+    const query = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: null,
+            },
+          },
+          errors: [{ message: "Could not determine age" }],
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data, errors } = await client.query({ query, errorPolicy: "all" });
+
+    expect(data).toEqual({
+      currentUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+      },
+    });
+
+    expect(errors).toEqual([{ message: "Could not determine age" }]);
+  });
+
+  test("warns and returns masked result when used with no-cache fetch policy", async () => {
+    using _ = spyOnConsole("warn");
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
+
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.query({ query, fetchPolicy: "no-cache" });
+
+    expect(data).toEqual({
+      currentUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+      },
+    });
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery");
+  });
+
+  test("does not warn on no-cache queries when data masking is disabled", async () => {
+    using _ = spyOnConsole("warn");
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
+
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: false,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.query({ query, fetchPolicy: "no-cache" });
+
+    expect(data).toEqual({
+      currentUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
+    });
+
+    expect(console.warn).not.toHaveBeenCalled();
+  });
+
+  test("does not warn on no-cache queries when all fragments use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
+
+    const query: MaskedDocumentNode<Query, never> = gql`
+      query MaskedQuery {
+        currentUser {
+          id
+          name
+          ...UserFields @unmask
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query },
+        result: {
+          data: {
+            currentUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.query({ query, fetchPolicy: "no-cache" });
 
     expect(data).toEqual({
-      currentUser: null,
+      currentUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
     });
 
-    expect(errors).toEqual([{ message: "User not logged in" }]);
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => {
-    const query = gql`
+  test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Query {
+      currentUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
+    }
+
+    const query: MaskedDocumentNode<Query, never> = gql`
       query MaskedQuery {
         currentUser {
           id
           name
-          ...UserFields
+          ...UserFields @unmask
         }
       }
 
       fragment UserFields on User {
         age
+        ...ProfileFields
+      }
+
+      fragment ProfileFields on User {
+        username
       }
     `;
 
@@ -3597,10 +4144,10 @@ describe("client.query", () => {
               __typename: "User",
               id: 1,
               name: "Test User",
-              age: null,
+              age: 30,
+              username: "testuser",
             },
           },
-          errors: [{ message: "Could not determine age" }],
         },
       },
     ];
@@ -3611,17 +4158,19 @@ describe("client.query", () => {
       link: new MockLink(mocks),
     });
 
-    const { data, errors } = await client.query({ query, errorPolicy: "all" });
+    const { data } = await client.query({ query, fetchPolicy: "no-cache" });
 
     expect(data).toEqual({
       currentUser: {
         __typename: "User",
         id: 1,
         name: "Test User",
+        age: 30,
       },
     });
 
-    expect(errors).toEqual([{ message: "Could not determine age" }]);
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery");
   });
 });
 
@@ -3810,23 +4359,259 @@ describe("client.subscribe", () => {
         data: {
           addedComment: null,
         },
-        errors: [{ message: "Something went wrong" }],
+        errors: [{ message: "Something went wrong" }],
+      },
+    });
+
+    const error = await stream.takeError();
+
+    expect(error).toEqual(
+      new ApolloError({ graphQLErrors: [{ message: "Something went wrong" }] })
+    );
+  });
+
+  test("handles errors returned from the subscription when errorPolicy is `all`", async () => {
+    const subscription = gql`
+      subscription NewCommentSubscription {
+        addedComment {
+          id
+          ...CommentFields
+        }
+      }
+
+      fragment CommentFields on Comment {
+        comment
+        author
+      }
+    `;
+
+    const link = new MockSubscriptionLink();
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link,
+    });
+
+    const observable = client.subscribe({
+      query: subscription,
+      errorPolicy: "all",
+    });
+    const stream = new ObservableStream(observable);
+
+    link.simulateResult({
+      result: {
+        data: {
+          addedComment: null,
+        },
+        errors: [{ message: "Something went wrong" }],
+      },
+    });
+
+    const { data, errors } = await stream.takeNext();
+
+    expect(data).toEqual({ addedComment: null });
+    expect(errors).toEqual([{ message: "Something went wrong" }]);
+  });
+
+  test("masks partial data for errors returned from the subscription when errorPolicy is `all`", async () => {
+    const subscription = gql`
+      subscription NewCommentSubscription {
+        addedComment {
+          id
+          ...CommentFields
+        }
+      }
+
+      fragment CommentFields on Comment {
+        comment
+        author
+      }
+    `;
+
+    const link = new MockSubscriptionLink();
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link,
+    });
+
+    const observable = client.subscribe({
+      query: subscription,
+      errorPolicy: "all",
+    });
+    const stream = new ObservableStream(observable);
+
+    link.simulateResult({
+      result: {
+        data: {
+          addedComment: {
+            __typename: "Comment",
+            id: 1,
+            comment: "Test comment",
+            author: null,
+          },
+        },
+        errors: [{ message: "Could not get author" }],
+      },
+    });
+
+    const { data, errors } = await stream.takeNext();
+
+    expect(data).toEqual({ addedComment: { __typename: "Comment", id: 1 } });
+    expect(errors).toEqual([{ message: "Could not get author" }]);
+  });
+
+  test("warns and returns masked result when used with no-cache fetch policy", async () => {
+    using _ = spyOnConsole("warn");
+    const subscription = gql`
+      subscription NewCommentSubscription {
+        addedComment {
+          id
+          ...CommentFields
+        }
+      }
+
+      fragment CommentFields on Comment {
+        comment
+        author
+      }
+    `;
+
+    const link = new MockSubscriptionLink();
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link,
+    });
+
+    const observable = client.subscribe({
+      query: subscription,
+      fetchPolicy: "no-cache",
+    });
+    const stream = new ObservableStream(observable);
+
+    link.simulateResult({
+      result: {
+        data: {
+          addedComment: {
+            __typename: "Comment",
+            id: 1,
+            comment: "Test comment",
+            author: "Test User",
+          },
+        },
+      },
+    });
+
+    {
+      const { data } = await stream.takeNext();
+
+      expect(data).toEqual({
+        addedComment: {
+          __typename: "Comment",
+          id: 1,
+        },
+      });
+    }
+
+    link.simulateResult({
+      result: {
+        data: {
+          addedComment: {
+            __typename: "Comment",
+            id: 2,
+            comment: "Test comment 2",
+            author: "Test User",
+          },
+        },
+      },
+    });
+
+    {
+      const { data } = await stream.takeNext();
+
+      expect(data).toEqual({
+        addedComment: {
+          __typename: "Comment",
+          id: 2,
+        },
+      });
+    }
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(
+      NO_CACHE_WARNING,
+      "NewCommentSubscription"
+    );
+  });
+
+  test("does not warn on no-cache queries when data masking is disabled", async () => {
+    using _ = spyOnConsole("warn");
+    const subscription = gql`
+      subscription NewCommentSubscription {
+        addedComment {
+          id
+          ...CommentFields
+        }
+      }
+
+      fragment CommentFields on Comment {
+        comment
+        author
+      }
+    `;
+
+    const link = new MockSubscriptionLink();
+
+    const client = new ApolloClient({
+      dataMasking: false,
+      cache: new InMemoryCache(),
+      link,
+    });
+
+    const observable = client.subscribe({
+      query: subscription,
+      fetchPolicy: "no-cache",
+    });
+    const stream = new ObservableStream(observable);
+
+    link.simulateResult({
+      result: {
+        data: {
+          addedComment: {
+            __typename: "Comment",
+            id: 1,
+            comment: "Test comment",
+            author: "Test User",
+          },
+        },
       },
     });
 
-    const error = await stream.takeError();
+    const { data } = await stream.takeNext();
 
-    expect(error).toEqual(
-      new ApolloError({ graphQLErrors: [{ message: "Something went wrong" }] })
-    );
+    expect(data).toEqual({
+      addedComment: {
+        __typename: "Comment",
+        id: 1,
+        comment: "Test comment",
+        author: "Test User",
+      },
+    });
+
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("handles errors returned from the subscription when errorPolicy is `all`", async () => {
+  test("does not warn on no-cache queries when all fragments use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
     const subscription = gql`
       subscription NewCommentSubscription {
         addedComment {
           id
-          ...CommentFields
+          ...CommentFields @unmask
         }
       }
 
@@ -3846,37 +4631,56 @@ describe("client.subscribe", () => {
 
     const observable = client.subscribe({
       query: subscription,
-      errorPolicy: "all",
+      fetchPolicy: "no-cache",
     });
     const stream = new ObservableStream(observable);
 
     link.simulateResult({
       result: {
         data: {
-          addedComment: null,
+          addedComment: {
+            __typename: "Comment",
+            id: 1,
+            comment: "Test comment",
+            author: "Test User",
+          },
         },
-        errors: [{ message: "Something went wrong" }],
       },
     });
 
-    const { data, errors } = await stream.takeNext();
+    const { data } = await stream.takeNext();
 
-    expect(data).toEqual({ addedComment: null });
-    expect(errors).toEqual([{ message: "Something went wrong" }]);
+    expect(data).toEqual({
+      addedComment: {
+        __typename: "Comment",
+        id: 1,
+        comment: "Test comment",
+        author: "Test User",
+      },
+    });
+
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("masks partial data for errors returned from the subscription when errorPolicy is `all`", async () => {
+  test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
     const subscription = gql`
       subscription NewCommentSubscription {
         addedComment {
           id
-          ...CommentFields
+          ...CommentFields @unmask
         }
       }
 
       fragment CommentFields on Comment {
         comment
-        author
+        author {
+          ...AuthorFields
+        }
+      }
+
+      fragment AuthorFields on User {
+        name
       }
     `;
 
@@ -3890,7 +4694,7 @@ describe("client.subscribe", () => {
 
     const observable = client.subscribe({
       query: subscription,
-      errorPolicy: "all",
+      fetchPolicy: "no-cache",
     });
     const stream = new ObservableStream(observable);
 
@@ -3901,17 +4705,28 @@ describe("client.subscribe", () => {
             __typename: "Comment",
             id: 1,
             comment: "Test comment",
-            author: null,
+            author: { __typename: "User", name: "Test User" },
           },
         },
-        errors: [{ message: "Could not get author" }],
       },
     });
 
-    const { data, errors } = await stream.takeNext();
+    const { data } = await stream.takeNext();
 
-    expect(data).toEqual({ addedComment: { __typename: "Comment", id: 1 } });
-    expect(errors).toEqual([{ message: "Could not get author" }]);
+    expect(data).toEqual({
+      addedComment: {
+        __typename: "Comment",
+        id: 1,
+        comment: "Test comment",
+        author: { __typename: "User" },
+      },
+    });
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(
+      NO_CACHE_WARNING,
+      "NewCommentSubscription"
+    );
   });
 });
 
@@ -4298,10 +5113,197 @@ describe("observableQuery.subscribeToMore", () => {
       });
     }
   });
-});
+});
+
+describe("client.mutate", () => {
+  test("masks data returned from client.mutate when dataMasking is `true`", async () => {
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Mutation {
+      updateUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & {
+        " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
+      };
+    }
+
+    const mutation: MaskedDocumentNode<Mutation, never> = gql`
+      mutation MaskedMutation {
+        updateUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query: mutation },
+        result: {
+          data: {
+            updateUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.mutate({ mutation });
+
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+      },
+    });
+  });
+
+  test("does not mask data returned from client.mutate when dataMasking is `false`", async () => {
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Mutation {
+      updateUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & {
+        " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
+      };
+    }
+
+    const mutation: TypedDocumentNode<Mutation, never> = gql`
+      mutation MaskedMutation {
+        updateUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query: mutation },
+        result: {
+          data: {
+            updateUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: false,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.mutate({ mutation });
+
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
+    });
+  });
+
+  test("does not mask data returned from client.mutate by default", async () => {
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Mutation {
+      updateUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & {
+        " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
+      };
+    }
+
+    const mutation: TypedDocumentNode<Mutation, never> = gql`
+      mutation MaskedMutation {
+        updateUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query: mutation },
+        result: {
+          data: {
+            updateUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
+        },
+      },
+    ];
+
+    const client = new ApolloClient({
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data } = await client.mutate({ mutation });
+
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
+    });
+  });
 
-describe("client.mutate", () => {
-  test("masks data returned from client.mutate when dataMasking is `true`", async () => {
+  test("does not mask data passed to update function", async () => {
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
@@ -4346,24 +5348,34 @@ describe("client.mutate", () => {
       },
     ];
 
+    const cache = new InMemoryCache();
     const client = new ApolloClient({
       dataMasking: true,
-      cache: new InMemoryCache(),
+      cache,
       link: new MockLink(mocks),
     });
 
-    const { data } = await client.mutate({ mutation });
+    const update = jest.fn();
+    await client.mutate({ mutation, update });
 
-    expect(data).toEqual({
-      updateUser: {
-        __typename: "User",
-        id: 1,
-        name: "Test User",
+    expect(update).toHaveBeenCalledTimes(1);
+    expect(update).toHaveBeenCalledWith(
+      cache,
+      {
+        data: {
+          updateUser: {
+            __typename: "User",
+            id: 1,
+            name: "Test User",
+            age: 30,
+          },
+        },
       },
-    });
+      { context: undefined, variables: {} }
+    );
   });
 
-  test("does not mask data returned from client.mutate when dataMasking is `false`", async () => {
+  test("handles errors returned when using errorPolicy `none`", async () => {
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
@@ -4378,7 +5390,7 @@ describe("client.mutate", () => {
       };
     }
 
-    const mutation: TypedDocumentNode<Mutation, never> = gql`
+    const mutation: MaskedDocumentNode<Mutation, never> = gql`
       mutation MaskedMutation {
         updateUser {
           id
@@ -4396,37 +5408,83 @@ describe("client.mutate", () => {
       {
         request: { query: mutation },
         result: {
-          data: {
-            updateUser: {
-              __typename: "User",
-              id: 1,
-              name: "Test User",
-              age: 30,
-            },
-          },
+          errors: [{ message: "User not logged in" }],
         },
       },
     ];
 
     const client = new ApolloClient({
-      dataMasking: false,
+      dataMasking: true,
       cache: new InMemoryCache(),
       link: new MockLink(mocks),
     });
 
-    const { data } = await client.mutate({ mutation });
+    await expect(
+      client.mutate({ mutation, errorPolicy: "none" })
+    ).rejects.toEqual(
+      new ApolloError({
+        graphQLErrors: [{ message: "User not logged in" }],
+      })
+    );
+  });
 
-    expect(data).toEqual({
-      updateUser: {
-        __typename: "User",
-        id: 1,
-        name: "Test User",
-        age: 30,
+  test("handles errors returned when using errorPolicy `all`", async () => {
+    type UserFieldsFragment = {
+      age: number;
+    } & { " $fragmentName"?: "UserFieldsFragment" };
+
+    interface Mutation {
+      updateUser:
+        | ({
+            __typename: "User";
+            id: number;
+            name: string;
+          } & {
+            " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
+          })
+        | null;
+    }
+
+    const mutation: MaskedDocumentNode<Mutation, never> = gql`
+      mutation MaskedMutation {
+        updateUser {
+          id
+          name
+          ...UserFields
+        }
+      }
+
+      fragment UserFields on User {
+        age
+      }
+    `;
+
+    const mocks = [
+      {
+        request: { query: mutation },
+        result: {
+          data: { updateUser: null },
+          errors: [{ message: "User not logged in" }],
+        },
       },
+    ];
+
+    const client = new ApolloClient({
+      dataMasking: true,
+      cache: new InMemoryCache(),
+      link: new MockLink(mocks),
+    });
+
+    const { data, errors } = await client.mutate({
+      mutation,
+      errorPolicy: "all",
     });
+
+    expect(data).toEqual({ updateUser: null });
+    expect(errors).toEqual([{ message: "User not logged in" }]);
   });
 
-  test("does not mask data returned from client.mutate by default", async () => {
+  test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => {
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
@@ -4441,7 +5499,7 @@ describe("client.mutate", () => {
       };
     }
 
-    const mutation: TypedDocumentNode<Mutation, never> = gql`
+    const mutation: MaskedDocumentNode<Mutation, never> = gql`
       mutation MaskedMutation {
         updateUser {
           id
@@ -4464,31 +5522,38 @@ describe("client.mutate", () => {
               __typename: "User",
               id: 1,
               name: "Test User",
-              age: 30,
+              age: null,
             },
           },
+          errors: [{ message: "Could not determine age" }],
         },
       },
     ];
 
     const client = new ApolloClient({
+      dataMasking: true,
       cache: new InMemoryCache(),
       link: new MockLink(mocks),
     });
 
-    const { data } = await client.mutate({ mutation });
+    const { data, errors } = await client.mutate({
+      mutation,
+      errorPolicy: "all",
+    });
 
     expect(data).toEqual({
       updateUser: {
         __typename: "User",
         id: 1,
         name: "Test User",
-        age: 30,
       },
     });
+
+    expect(errors).toEqual([{ message: "Could not determine age" }]);
   });
 
-  test("does not mask data passed to update function", async () => {
+  test("warns and returns masked result when used with no-cache fetch policy", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
@@ -4533,34 +5598,31 @@ describe("client.mutate", () => {
       },
     ];
 
-    const cache = new InMemoryCache();
     const client = new ApolloClient({
       dataMasking: true,
-      cache,
+      cache: new InMemoryCache(),
       link: new MockLink(mocks),
     });
 
-    const update = jest.fn();
-    await client.mutate({ mutation, update });
+    const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" });
 
-    expect(update).toHaveBeenCalledTimes(1);
-    expect(update).toHaveBeenCalledWith(
-      cache,
-      {
-        data: {
-          updateUser: {
-            __typename: "User",
-            id: 1,
-            name: "Test User",
-            age: 30,
-          },
-        },
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
       },
-      { context: undefined, variables: {} }
+    });
+
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(
+      NO_CACHE_WARNING,
+      "MaskedMutation"
     );
   });
 
-  test("handles errors returned when using errorPolicy `none`", async () => {
+  test("does not warn on no-cache queries when data masking is disabled", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
@@ -4593,41 +5655,52 @@ describe("client.mutate", () => {
       {
         request: { query: mutation },
         result: {
-          errors: [{ message: "User not logged in" }],
+          data: {
+            updateUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
         },
       },
     ];
 
     const client = new ApolloClient({
-      dataMasking: true,
+      dataMasking: false,
       cache: new InMemoryCache(),
       link: new MockLink(mocks),
     });
 
-    await expect(
-      client.mutate({ mutation, errorPolicy: "none" })
-    ).rejects.toEqual(
-      new ApolloError({
-        graphQLErrors: [{ message: "User not logged in" }],
-      })
-    );
+    const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" });
+
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
+    });
+
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("handles errors returned when using errorPolicy `all`", async () => {
+  test("does not warn on no-cache queries when all fragments use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
       age: number;
     } & { " $fragmentName"?: "UserFieldsFragment" };
 
     interface Mutation {
-      updateUser:
-        | ({
-            __typename: "User";
-            id: number;
-            name: string;
-          } & {
-            " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
-          })
-        | null;
+      updateUser: {
+        __typename: "User";
+        id: number;
+        name: string;
+      } & {
+        " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
+      };
     }
 
     const mutation: MaskedDocumentNode<Mutation, never> = gql`
@@ -4635,7 +5708,7 @@ describe("client.mutate", () => {
         updateUser {
           id
           name
-          ...UserFields
+          ...UserFields @unmask
         }
       }
 
@@ -4648,8 +5721,14 @@ describe("client.mutate", () => {
       {
         request: { query: mutation },
         result: {
-          data: { updateUser: null },
-          errors: [{ message: "User not logged in" }],
+          data: {
+            updateUser: {
+              __typename: "User",
+              id: 1,
+              name: "Test User",
+              age: 30,
+            },
+          },
         },
       },
     ];
@@ -4660,19 +5739,30 @@ describe("client.mutate", () => {
       link: new MockLink(mocks),
     });
 
-    const { data, errors } = await client.mutate({
-      mutation,
-      errorPolicy: "all",
+    const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" });
+
+    expect(data).toEqual({
+      updateUser: {
+        __typename: "User",
+        id: 1,
+        name: "Test User",
+        age: 30,
+      },
     });
 
-    expect(data).toEqual({ updateUser: null });
-    expect(errors).toEqual([{ message: "User not logged in" }]);
+    expect(console.warn).not.toHaveBeenCalled();
   });
 
-  test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => {
+  test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => {
+    using _ = spyOnConsole("warn");
     type UserFieldsFragment = {
       age: number;
-    } & { " $fragmentName"?: "UserFieldsFragment" };
+    } & { " $fragmentName"?: "UserFieldsFragment" } & {
+      " $fragmentRefs"?: { ProfileFieldsFragment: ProfileFieldsFragment };
+    };
+    type ProfileFieldsFragment = {
+      username: number;
+    } & { " $fragmentName"?: "ProfileFieldsFragment" };
 
     interface Mutation {
       updateUser: {
@@ -4689,12 +5779,17 @@ describe("client.mutate", () => {
         updateUser {
           id
           name
-          ...UserFields
+          ...UserFields @unmask
         }
       }
 
       fragment UserFields on User {
         age
+        ...ProfileFieldsFragment
+      }
+
+      fragment ProfileFieldsFragment on User {
+        username
       }
     `;
 
@@ -4707,10 +5802,10 @@ describe("client.mutate", () => {
               __typename: "User",
               id: 1,
               name: "Test User",
-              age: null,
+              age: 30,
+              username: "testuser",
             },
           },
-          errors: [{ message: "Could not determine age" }],
         },
       },
     ];
@@ -4721,20 +5816,22 @@ describe("client.mutate", () => {
       link: new MockLink(mocks),
     });
 
-    const { data, errors } = await client.mutate({
-      mutation,
-      errorPolicy: "all",
-    });
+    const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" });
 
     expect(data).toEqual({
       updateUser: {
         __typename: "User",
         id: 1,
         name: "Test User",
+        age: 30,
       },
     });
 
-    expect(errors).toEqual([{ message: "Could not determine age" }]);
+    expect(console.warn).toHaveBeenCalledTimes(1);
+    expect(console.warn).toHaveBeenCalledWith(
+      NO_CACHE_WARNING,
+      "MaskedMutation"
+    );
   });
 });