Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unmask types with explicit enabled: false and default to leave the type alone #12252

Open
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

jerelmiller
Copy link
Member

@jerelmiller jerelmiller commented Jan 6, 2025

Fixes #12248
Fixes #12251

After enough feedback from users upgrading to 3.12.x, we feel the default behavior of applying Unmasked with MaybeMasked is the wrong default since this does type modification. That change has made it difficult for those currently using GraphQL Codegen's fragment masking feature and has introduced regressions in TypeScript compilation performance (#12248) simply from upgrading from an older version of the client.

This PR changes the default behavior of MaybeMasked to do nothing with the types unless otherwise specified. The enabled flag has been deprecated in favor of a new mode property which better describes the two different behaviors used for TypeScript:

preserveTypes

This is the new default and will simply leave TData alone with no type modification. Since this is the new default, users don't need to set this to get this behavior.

unmask

This will unwrap all operation types by default into their unmasked form. This is the same as the 3.12.4 or below default. Users will now need to explicitly opt into using the unmasked types

Setting the mode

To set the mode, you use the same declaration merging syntax as before:

declare module "@apollo/client" {
  interface DataMasking {
    mode: "unmask"
  }
}

Migrating from 3.12.4 or below

If you've opted into masked types by setting the enabled property to true, you can remove this configuration entirely:

-declare module "@apollo/client" {
-  interface DataMasking {
-    mode: "unmask"
-  }
-}

If you prefer to specify the behavior explicitly, switch from enabled: true, to mode: "preserveTypes":

declare module "@apollo/client" {
  interface DataMasking {
-    enabled: true
+    mode: "preserveTypes"
  }
}

If you rely on the default behavior in 3.12.4 or below and would like to continue to use unmasked types by default, set the mode to unmask:

declare module "@apollo/client" {
  interface DataMasking {
    mode: "unmask"
  }
}

@svc-apollo-docs
Copy link

svc-apollo-docs commented Jan 6, 2025

✅ Docs Preview Ready

Configuration
{
  "repoOverrides": {
    "apollographql/apollo-client@main": {
      "remote": {
        "owner": "apollographql",
        "repo": "apollo-client",
        "branch": "jerel/maybe-masked-defaults"
      }
    }
  }
}
Name Link

Commit

df9d0db

Preview

https://www.apollographql.com/docs/deploy-preview/4fb7969353bb25661258

Build ID

4fb7969353bb25661258

File Changes

0 new, 1 changed, 0 removed
* (developer-tools)/react/(latest)/data/fragments.mdx

Pages

1


1 pages published. Build will be available for 30 days.

Copy link

changeset-bot bot commented Jan 6, 2025

🦋 Changeset detected

Latest commit: 20664cb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

pkg-pr-new bot commented Jan 6, 2025

npm i https://pkg.pr.new/@apollo/client@12252

commit: 20664cb

Copy link

netlify bot commented Jan 6, 2025

Deploy Preview for apollo-client-docs ready!

Name Link
🔨 Latest commit 20664cb
🔍 Latest deploy log https://app.netlify.com/sites/apollo-client-docs/deploys/677f1bd2eb92e30008da7484
😎 Deploy Preview https://deploy-preview-12252--apollo-client-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link
Contributor

github-actions bot commented Jan 6, 2025

size-limit report 📦

Path Size
dist/apollo-client.min.cjs 40.66 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" 50.07 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" (production) 47.18 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" 36.18 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" (production) 33.58 KB (0%)
import { ApolloProvider } from "dist/react/index.js" 1.26 KB (0%)
import { ApolloProvider } from "dist/react/index.js" (production) 1.24 KB (0%)
import { useQuery } from "dist/react/index.js" 5.21 KB (0%)
import { useQuery } from "dist/react/index.js" (production) 4.29 KB (0%)
import { useLazyQuery } from "dist/react/index.js" 5.7 KB (0%)
import { useLazyQuery } from "dist/react/index.js" (production) 4.78 KB (0%)
import { useMutation } from "dist/react/index.js" 3.62 KB (0%)
import { useMutation } from "dist/react/index.js" (production) 2.84 KB (0%)
import { useSubscription } from "dist/react/index.js" 4.42 KB (0%)
import { useSubscription } from "dist/react/index.js" (production) 3.48 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" 5.51 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" (production) 4.17 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" 5.01 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" (production) 3.66 KB (0%)
import { useLoadableQuery } from "dist/react/index.js" 5.09 KB (0%)
import { useLoadableQuery } from "dist/react/index.js" (production) 3.74 KB (0%)
import { useReadQuery } from "dist/react/index.js" 3.41 KB (0%)
import { useReadQuery } from "dist/react/index.js" (production) 3.35 KB (0%)
import { useFragment } from "dist/react/index.js" 2.36 KB (0%)
import { useFragment } from "dist/react/index.js" (production) 2.31 KB (0%)

@jerelmiller jerelmiller added auto-cleanup 🤖 🎭 data-masking Issues/PRs related to data masking labels Jan 6, 2025
@jerelmiller jerelmiller requested a review from phryneas January 6, 2025 23:39
@jerelmiller
Copy link
Member Author

@phryneas this PR is good to review code-wise, but I'm working on the docs updates along with this, hence the draft status. Feel free to add suggestions though for the code itself 🙂

phryneas
phryneas previously approved these changes Jan 7, 2025
Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me code-wise. Once you are happy with the word-smithing, please feel free to get this out

@jerelmiller jerelmiller marked this pull request as ready for review January 9, 2025 00:14
@jerelmiller jerelmiller requested a review from a team as a code owner January 9, 2025 00:14
@jerelmiller jerelmiller requested a review from phryneas January 9, 2025 00:20
@@ -44,8 +44,11 @@ export type MaybeMasked<TData> =
// prevent "Type instantiation is excessively deep and possibly infinite."
true extends IsAny<TData> ? TData
: TData extends { __masked?: true } ? Prettify<RemoveMaskedMarker<TData>>
: DataMasking extends { enabled: true } ? TData
: true extends ContainsFragmentsRefs<TData> ? Unmasked<TData>
: DataMasking extends { mode: "preserveTypes" } ? TData
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to make this explicit even though it is the default behavior without this line, that way we have a reference to it in code in case someone searches this codebase (see the docs changes for how this is conveyed in docs).

@@ -837,12 +837,12 @@ It's important that the parent query or fragment selects any [`keyFields`](../ca

### Nesting fragments in other fragments

As your UI grows in complexity, it is common to split up components into smaller, more reusable chunks. As a result you may end up with more deeply nested components that have their own data requirements. Much like queries, we can nest fragments within other fragments.

<Note>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to swap the order of this note and the sentence above as I think it reads a bit better since it now includes an introduction before the side comment.

You can opt in to use the masked types in one of two ways.

##### Opting in globally
Additionally, if you use the [generated `useFragment` function](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#the-usefragment-helper), you must use Apollo Client's [`useFragment`](#usefragment) hook instead.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it makes sense to include a note on migrating from codegen's fragment masking feature. I had a difficult time figuring out how to mention it.

The steps to migrate are roughly:

  • Migrate to Apollo Client's useFragment hook first
  • Disable fragment masking feature in codegen config
  • Enable dataMasking on the client

The key thing here is that dataMasking should remain off until fully migrated, otherwise it messes with the fragment masking feature since it needs the full result type.

That said, these 3 things are already sorta listed in this section, just not as a "migrate away from" type way. Do you think there is anything more to say or do you think there is enough here that makes it obvious what needs to be done?


To turn on masked types for your whole application at once, modify the `DataMasking` exported type from `@apollo/client` using TypeScript's [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) ability.
<MinVersion version="3.12.5">
#### Setting a types mode for masked types
Copy link
Member Author

@jerelmiller jerelmiller Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the biggest change in the docs for this PR. With the move to mode instead of enabled, it didn't make much sense to talk about this as a "opt into masked types", since really all that was doing was preserving the type and preventing it from being unwrapped. I've oriented this now around the modes and what they do.

I've provided a migration path for 3.12.5 for those that want/need it in the PR description. Is that something we should include in the docs as well, or should we point to the PR for these instructions instead?

##### Modes

<Note>
Prior to Apollo Client version 3.12.5, the default was to [unmask](#unmask) the types. If you upgrade to 3.12.5 or later and wish to maintain the same default as 3.12.4 or below, set the mode to [`unmask`](#unmask). See pull request [#12252](https://github.com/apollographql/apollo-client/pull/12252) for more details on this change.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Prior to Apollo Client version 3.12.5, the default was to [unmask](#unmask) the types. If you upgrade to 3.12.5 or later and wish to maintain the same default as 3.12.4 or below, set the mode to [`unmask`](#unmask). See pull request [#12252](https://github.com/apollographql/apollo-client/pull/12252) for more details on this change.
Prior to Apollo Client version 3.12.5, the default was to [unmask](#unmask) the types. If you upgrade to 3.12.5 or later and wish to maintain the same default as 3.12.4 or below, set the mode to [`unmask`](#unmask). See pull request [#12252](https://github.com/apollographql/apollo-client/pull/12252) for more details on this change, including a section on migrating from 3.12.4 or below.

Based on my previous comment, perhaps this is enough to satisfy communication on migrating to this new change?


##### Opting in per operation
Setting the mode to `preserveTypes` makes no modification to the operation types regardless of whether the type definitions are masked or unmasked. This provides a simpler upgrade path when you're ready to [incrementally adopt](#incremental-adoption-in-an-existing-application) data masking.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I mention this is the default behavior in a followup sentence, or is the header clear enough that this is the default behavior?

@@ -1419,9 +1423,9 @@ new ApolloClient({

> Enabling data masking early in the adoption process makes it much easier to adopt for newly added queries and fragments since masking becomes the default behavior. Ideally data masking is enabled in the same pull request as the `@unmask` changes to ensure that no new queries and fragments are introduced to the codebase without the `@unmask` modifications applied.

#### 3. Generate and opt in to use masked types
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There really is no "opt in" anymore after this change so I tried to remove that language everywhere I could.

@jerelmiller
Copy link
Member Author

@Meschreiber the docs changes should be good to review now, barring any significant feedback that the switch to mode is a bad idea. Let me know what you think!

@jerelmiller jerelmiller dismissed phryneas’s stale review January 9, 2025 00:36

I switched the approach and would prefer a new review

@shorgi
Copy link
Contributor

shorgi commented Jan 9, 2025

!docs preview

@@ -44,7 +44,8 @@ export type MaybeMasked<TData> =
// prevent "Type instantiation is excessively deep and possibly infinite."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest moving the DataMasking tests up:

export type MaybeMasked<TData> =
  DataMasking extends { mode: "unmask" } ?
    // distribute TData - in case of a union, do the next steps for each member
    TData extends any ?
      // prevent "Type instantiation is excessively deep and possibly infinite."
      true extends IsAny<TData> ? TData
      : TData extends { __masked?: true } ? Prettify<RemoveMaskedMarker<TData>>
      : true extends ContainsFragmentsRefs<TData> ? Unmasked<TData>
      : TData
    : never
  : DataMasking extends { mode: "preserveTypes" } ? TData
  : TData;

This way, in cases where mode is not "unmask", the types won't be touched at all.

While that might seem counterintuitive, the benefit is that TData itself will not need to be inspected at all, which enables cases where TData is unknown at the time of invoking MaybeMasked.

Here is a new test that shows the problem:

test("MaybeMasked can be called with a generic if `mode` is not set to `unmask`", (prefix) => {
  function withGenericResult<T extends { [key: string]: string }>(
    arg: TypedDocumentNode<T, {}>
  ) {
    bench(prefix + "Result generic - instantiations", () => {
      const maybeMasked: MaybeMasked<typeof arg> = arg;
      return maybeMasked;
    }).types();

    bench(prefix + "Result generic - functionality", () => {
      const maybeMasked: MaybeMasked<typeof arg> = arg;
      expectTypeOf(maybeMasked).toEqualTypeOf(arg);
    });
  }
  function withGenericDocument<T extends TypedDocumentNode>(arg: T) {
    bench(prefix + "Result generic - instantiations", () => {
      const maybeMasked: MaybeMasked<T> = arg;
      return maybeMasked;
    }).types();

    bench(prefix + "Result generic - functionality", () => {
      const maybeMasked: MaybeMasked<T> = arg;
      // cannot use unresolved generic with `expectTypeOf` here so we just try an assignment the other way round
      const test: T = maybeMasked;
      return test;
    });
  }
  withGenericResult({} as any);
  withGenericDocument({} as any);
});

The drawback is that the Masked marker would not be removed, but if you don't set { mode: "unmask" } I don't think Masked has any use case, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auto-cleanup 🤖 🎭 data-masking Issues/PRs related to data masking
Projects
None yet
5 participants