Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/defer-named-spread-optionality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@graphql-codegen/visitor-plugin-common': patch
'@graphql-codegen/typescript-operations': patch
---

Keep deferred-only fields optional when a named `@defer` spread overlaps a field also selected outside the spread
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,20 @@ export class SelectionSetToObject<
*/
protected buildFragmentSpreadsUsage(
spreads: FragmentSpreadNode[],
): Record<string, FragmentSpreadUsage[]> {
const selectionNodesByTypeName: Record<string, FragmentSpreadUsage[]> = {};
): Record<string, Array<FragmentSpreadUsage | EnrichedFieldNode>> {
const selectionNodesByTypeName: Record<
string,
Array<FragmentSpreadUsage | EnrichedFieldNode>
> = {};

for (const spread of spreads) {
const fragmentSpreadObject = this._loadedFragments.find(lf => lf.name === spread.name.value);

if (fragmentSpreadObject) {
const schemaType = this._schema.getType(fragmentSpreadObject.onType);
const possibleTypesForFragment = getPossibleTypes(this._schema, schemaType);
const fragmentDirectives = [...(spread.directives || [])];
const isIncremental = hasIncrementalDeliveryDirectives(fragmentDirectives);

for (const possibleType of possibleTypesForFragment) {
const fragmentSuffix = this._getFragmentSuffix(spread.name.value);
Expand All @@ -302,13 +307,40 @@ export class SelectionSetToObject<

selectionNodesByTypeName[possibleType.name] ||= [];

// Expand @defer/@stream named spreads into per-selection nodes so each
// deferred field becomes its own optional union (same as inline @defer).
// Keeping them as one node collapses optionality when a field (e.g. `id`)
// is also selected outside the deferred spread.
// Skip when fragment masking is enabled — the Incremental<> fragment ref
// already models deferred optionality at the fragment boundary.
if (isIncremental && this._config.inlineFragmentTypes !== 'mask') {
for (const selection of fragmentSpreadObject.node.selectionSet.selections) {
if (selection.kind === Kind.FIELD) {
selectionNodesByTypeName[possibleType.name].push({
...selection,
fragmentDirectives,
});
continue;
}

selectionNodesByTypeName[possibleType.name].push({
fragmentName: spread.name.value,
typeName: usage,
onType: fragmentSpreadObject.onType,
selectionNodes: [selection],
fragmentDirectives,
});
}
continue;
}

const fragmentSelectionNodes: FragmentSpreadUsage['selectionNodes'] = [
...fragmentSpreadObject.node.selectionSet.selections,
].map(originalNode => {
if (originalNode.kind === Kind.FIELD) {
return {
...originalNode,
fragmentDirectives: [...(spread.directives || [])],
fragmentDirectives,
} satisfies EnrichedFieldNode;
}
return originalNode;
Expand All @@ -319,7 +351,7 @@ export class SelectionSetToObject<
typeName: usage,
onType: fragmentSpreadObject.onType,
selectionNodes: fragmentSelectionNodes,
fragmentDirectives: [...(spread.directives || [])],
fragmentDirectives,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1109,12 +1109,12 @@ describe('TypeScript Operations Plugin - @include and @skip with @defer', () =>
"export type UserSkipQueryVariables = Exact<{ [key: string]: never; }>;


export type UserSkipQuery = { user: { id: string } & { name?: string, niName?: string } & { age?: number, createdAt?: string } & ({ age: number, createdAt: string } | { age?: never, createdAt?: never }) | null };
export type UserSkipQuery = { user: { id: string } & { name?: string, niName?: string } & { age?: number, createdAt?: string } & ({ age?: number } | { age?: never }) & ({ createdAt?: string } | { createdAt?: never }) | null };

export type UserIncludeQueryVariables = Exact<{ [key: string]: never; }>;


export type UserIncludeQuery = { user: { id: string } & { name?: string, niName?: string } & { age?: number, createdAt?: string } & ({ age: number, createdAt: string } | { age?: never, createdAt?: never }) | null };
export type UserIncludeQuery = { user: { id: string } & { name?: string, niName?: string } & { age?: number, createdAt?: string } & ({ age?: number } | { age?: never }) & ({ createdAt?: string } | { createdAt?: never }) | null };

export type User_NameFragment = { name: string, niName: string };

Expand Down
67 changes: 65 additions & 2 deletions packages/plugins/typescript/operations/tests/ts-documents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5586,7 +5586,7 @@ function test(q: GetEntityBrandDataQuery): void {
export type UserQueryVariables = Exact<{ [key: string]: never; }>;


export type UserQuery = { user: { clearanceLevel: string, name: string, phone: { home: string }, employment: { title: string } } & ({ email: string } | { email?: never }) & ({ address: { street1: string } } | { address?: never }) & ({ widgetCount: number, widgetPreference: string } | { widgetCount?: never, widgetPreference?: never }) & ({ favoriteFood: string, leastFavoriteFood: string } | { favoriteFood?: never, leastFavoriteFood?: never }) };
export type UserQuery = { user: { clearanceLevel: string, name: string, phone: { home: string }, employment: { title: string } } & ({ email: string } | { email?: never }) & ({ address: { street1: string } } | { address?: never }) & ({ widgetCount: number } | { widgetCount?: never }) & ({ widgetPreference: string } | { widgetPreference?: never }) & ({ favoriteFood: string } | { favoriteFood?: never }) & ({ leastFavoriteFood: string } | { leastFavoriteFood?: never }) };
"
`);
});
Expand Down Expand Up @@ -5678,7 +5678,7 @@ function test(q: GetEntityBrandDataQuery): void {
export type UserQueryVariables = Exact<{ [key: string]: never; }>;


export type UserQuery = { user: { clearanceLevel: string, name: string, phone: { home: string }, employment: { title: string } } & ({ email: string } | { email?: never }) & ({ address: { street1: string | 'specialType' } } | { address?: never }) & ({ widgetName: string, widgetCount: number } | { widgetName?: never, widgetCount?: never }) };
export type UserQuery = { user: { clearanceLevel: string, name: string, phone: { home: string }, employment: { title: string } } & ({ email: string } | { email?: never }) & ({ address: { street1: string | 'specialType' } } | { address?: never }) & ({ widgetName: string } | { widgetName?: never }) & ({ widgetCount: number } | { widgetCount?: never }) };
"
`);
});
Expand Down Expand Up @@ -5795,6 +5795,69 @@ function test(q: GetEntityBrandDataQuery): void {
"
`);
});

it('keeps deferred-only fields optional when a named @defer spread overlaps a selected field', async () => {
// When `id` is selected both outside and inside a @defer named spread, bundling
// the spread as one union collapses optionality: intersecting with outer `id`
// eliminates the absent branch, so deferred-only fields become required.
// Each deferred selection must be its own optional union (same as inline @defer).
const schema = buildSchema(/* GraphQL */ `
type User {
id: ID!
name: String!
connected: Boolean!
}

type Query {
user: User!
}
`);

const document = parse(/* GraphQL */ `
fragment UserDeferred on User {
id
connected
}

query user {
user {
id
name
...UserDeferred @defer
}
}
`);

const { content } = await plugin(
schema,
[{ location: '', document }],
{},
{ outputFile: 'graphql.ts' },
);

expect(content).toMatchInlineSnapshot(`
"export type UserDeferredFragment = { id: string, connected: boolean };

export type UserQueryVariables = Exact<{ [key: string]: never; }>;


export type UserQuery = { user: { id: string, name: string } & ({ id: string } | { id?: never }) & ({ connected: boolean } | { connected?: never }) };
"
`);

await validate(
content,
`
declare function useUserQuery(): UserQuery;
const data = useUserQuery();
// deferred-only field must remain optional despite overlapping \`id\`
const connected: boolean | undefined = data.user.connected;
// required non-deferred fields stay required
const id: string = data.user.id;
const name: string = data.user.name;
`,
);
});
});

it('handles unnamed queries', async () => {
Expand Down