Skip to content

@include/@skip on a fragment spread disables fragment masking (fields are inlined instead of $fragmentRefs) #10896

Description

@izumin5210

Which packages are impacted by your issue?

@graphql-codegen/visitor-plugin-common, @graphql-codegen/client-preset, @graphql-codegen/typescript-operations

Describe the bug

With inlineFragmentTypes: 'mask' (the client preset default), putting @include or @skip on a fragment spread disables fragment masking for that spread: the fragment's fields are inlined into the parent operation type as optional fields, and the ' $fragmentRefs' reference disappears.

As a result, passing the parent object to a component that accepts FragmentType<typeof XFragment> becomes a type error, and useFragment can no longer unmask it. Codebases relying on masked spreads are forced to either restructure their queries or work around it at the type level (casts / @ts-expect-error).

The @defer path is not affected: a spread with @defer keeps masking and emits ' $fragmentRefs'?: { 'XFragment': Incremental<XFragment> }. So conditional directives and incremental directives — which are handled by adjacent code paths — behave asymmetrically.

This appears to be an accidental regression of the conditional-directives rework rather than an intentional change:

  • the mask branch exists in the incremental (@defer) path (selection-set-to-object.ts, from #9196) but is missing from the conditional path introduced by #10645 / #10646 (merged into v6 via #10496);
  • the dedicated test suite (ts-documents.skip-include-directives.spec.ts) has no coverage for inlineFragmentTypes: 'mask';
  • neither the changelog nor the v5→v6 migration guide mentions the behavior change.

Your Example Website or App

N/A (minimal reproduction below)

Steps to Reproduce the Bug or Issue

  1. Generate types with the client preset (fragment masking on by default) for:
# schema
type Query {
  user: User
}

type User {
  id: ID!
  nicknames: [String!]
}
# operation
query GetUser($withNicknames: Boolean!) {
  user {
    id
    ...UserNicknames @include(if: $withNicknames)
  }
}

fragment UserNicknames on User {
  nicknames
}
  1. Inspect the generated GetUserQuery type.

Actual output (client-preset 6.1.0):

export type GetUserQuery = {
  user: ({ id: string } & { nicknames?: Array<string> | null }) | null;
};

The fragment is inlined; ' $fragmentRefs' is gone, so FragmentType<typeof UserNicknamesFragmentDoc> is no longer satisfied and useFragment rejects the value.

Removing @include(if: $withNicknames) restores masking:

export type GetUserQuery = {
  user:
    | ({ id: string } & { ' $fragmentRefs'?: { UserNicknamesFragment: UserNicknamesFragment } })
    | null;
};

Version matrix (observed)

client-preset plain spread ...X @include(if:) ...X @defer
5.0.2 masked masked (directive ignored in types — unsound in the opposite direction) masked
6.0.1 masked unmasked, fields inlined masked
6.1.0 masked unmasked, fields inlined masked

Expected behavior

The spread stays masked and the type reflects that the fragment may be absent, e.g. with an optional ref key:

export type GetUserQuery = {
  user:
    | ({ id: string } & { ' $fragmentRefs'?: { UserNicknamesFragment?: UserNicknamesFragment } })
    | null;
};

…mirroring how @defer keeps masking via Incremental<...>.

Note that for this to be consumable, the fragment-masking helpers also need to accept optional refs (FragmentType requires the inner key, so an optional key is not assignable to it, and no useFragment overload accepts it).

Screenshots or Videos

N/A

Platform

  • OS: macOS
  • NodeJS: 24
  • graphql version: 16.x
  • @graphql-codegen/cli: 6.x
  • @graphql-codegen/client-preset: 6.1.0 (also reproduced on 6.0.1)
  • @graphql-codegen/visitor-plugin-common: 7.x (also reproduces on current master)

Codegen Config File

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'schema.graphql',
  documents: ['src/**/*.ts'],
  generates: {
    './src/gql/': {
      preset: 'client',
    },
  },
};

export default config;

Additional context

Root cause (in packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts, _buildGroupedSelections):

  • Fragment spreads carrying @skip/@include are diverted into selectionNodesByTypeNameConditional and handled by the conditionalDirectivesFound block, which unconditionally inlines every field of the fragment as optional. That block never checks this._config.inlineFragmentTypes === 'mask'.
  • The adjacent incrementalDirectivesFound (@defer) block does check inlineFragmentTypes === 'mask' and preserves masking (this branch dates back to #9196). The check was not carried over when conditional-directive support was reworked in #10645 / #10646 / #10496.
  • A spread carrying both @include/@skip and @defer currently goes through both blocks and double-emits: inlined optional fields and the deferred masked ref.

Related: #10881 tracks an adjacent symptom of the same code path in inlineFragmentTypes: 'inline' mode (fields of conditional spreads silently disappearing). This issue is specifically about the mask mode regression.

I have a fix ready (mask branch for the conditional path + OptionalFragmentType / useFragment overloads in the client preset so the optional refs are consumable) and will open a PR referencing this issue.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions