dotansimha / dotansimha/graphql-code-generator
`@include`/`@skip` on a fragment spread disables fragment masking (fields are inlined instead of ` $fragmentRefs`)
- Dominant language
- TypeScript
- Stars
- 11.3k
- Forks
- 1.4k
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 23
Description
### 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` 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 }`. 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:
```graphql
# schema
type Query {
user: User
}
type User {
id: ID!
nicknames: [String!]
}
```
```graphql
# operation
query GetUser($withNicknames: Boolean!) {
user {
id
...UserNicknames @include(if: $withNicknames)
}
}
fragment UserNicknames on User {
nicknames
}
```
2. Inspect the generated `GetUserQuery` type.
**Actual output (client-preset 6.1.0):**
```ts
export type GetUserQuery = {
user: ({ id: string } & { nicknames?: Array | null }) | null;
};
```
The fragment is inlined; `' $fragmentRefs'` is gone, so `FragmentType` is no longer satisfied and `useFragment` rejects the value.
Removing `@include(if: $withNicknames)` restores masking:
```ts
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:
```ts
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
```ts
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: dotansimha/graphql-code-generator#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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.