dotansimha / dotansimha/graphql-code-generator

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

Open
#10,896 4 comments 1 reaction 1 assignee Claimed by @eddeee888 View on GitHub
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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.