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
- 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
}
- 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.
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@includeor@skipon 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, anduseFragmentcan 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
@deferpath is not affected: a spread with@deferkeeps 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:
@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);ts-documents.skip-include-directives.spec.ts) has no coverage forinlineFragmentTypes: 'mask';Your Example Website or App
N/A (minimal reproduction below)
Steps to Reproduce the Bug or Issue
GetUserQuerytype.Actual output (client-preset 6.1.0):
The fragment is inlined;
' $fragmentRefs'is gone, soFragmentType<typeof UserNicknamesFragmentDoc>is no longer satisfied anduseFragmentrejects the value.Removing
@include(if: $withNicknames)restores masking:Version matrix (observed)
...X @include(if:)...X @deferExpected behavior
The spread stays masked and the type reflects that the fragment may be absent, e.g. with an optional ref key:
…mirroring how
@deferkeeps masking viaIncremental<...>.Note that for this to be consumable, the fragment-masking helpers also need to accept optional refs (
FragmentTyperequires the inner key, so an optional key is not assignable to it, and nouseFragmentoverload accepts it).Screenshots or Videos
N/A
Platform
graphqlversion: 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 currentmaster)Codegen Config File
Additional context
Root cause (in
packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts,_buildGroupedSelections):@skip/@includeare diverted intoselectionNodesByTypeNameConditionaland handled by theconditionalDirectivesFoundblock, which unconditionally inlines every field of the fragment as optional. That block never checksthis._config.inlineFragmentTypes === 'mask'.incrementalDirectivesFound(@defer) block does checkinlineFragmentTypes === '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.@include/@skipand@defercurrently 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 themaskmode regression.I have a fix ready (mask branch for the conditional path +
OptionalFragmentType/useFragmentoverloads in the client preset so the optional refs are consumable) and will open a PR referencing this issue.