From 8f44a85f6be8cab5fc3d593ee59f7806bb2831e6 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Wed, 15 Jul 2026 22:55:24 +0200 Subject: [PATCH] fix(visitor-plugin-common): keep deferred fields optional with overlapping named @defer spreads Expand @defer/@stream named fragment spreads into per-selection nodes so intersecting an outer field like `id` no longer collapses deferred-only fields to required. Co-authored-by: Cursor --- .changeset/defer-named-spread-optionality.md | 6 ++ .../src/selection-set-to-object.ts | 40 +++++++++-- ...-documents.skip-include-directives.spec.ts | 4 +- .../operations/tests/ts-documents.spec.ts | 67 ++++++++++++++++++- 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 .changeset/defer-named-spread-optionality.md diff --git a/.changeset/defer-named-spread-optionality.md b/.changeset/defer-named-spread-optionality.md new file mode 100644 index 00000000000..362e43b4405 --- /dev/null +++ b/.changeset/defer-named-spread-optionality.md @@ -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 diff --git a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts index ff6e62ccf44..440135643d0 100644 --- a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts +++ b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts @@ -282,8 +282,11 @@ export class SelectionSetToObject< */ protected buildFragmentSpreadsUsage( spreads: FragmentSpreadNode[], - ): Record { - const selectionNodesByTypeName: Record = {}; + ): Record> { + const selectionNodesByTypeName: Record< + string, + Array + > = {}; for (const spread of spreads) { const fragmentSpreadObject = this._loadedFragments.find(lf => lf.name === spread.name.value); @@ -291,6 +294,8 @@ export class SelectionSetToObject< 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); @@ -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; @@ -319,7 +351,7 @@ export class SelectionSetToObject< typeName: usage, onType: fragmentSpreadObject.onType, selectionNodes: fragmentSelectionNodes, - fragmentDirectives: [...(spread.directives || [])], + fragmentDirectives, }); } } diff --git a/packages/plugins/typescript/operations/tests/ts-documents.skip-include-directives.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.skip-include-directives.spec.ts index baaf7c5d8c1..c89916841e3 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.skip-include-directives.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.skip-include-directives.spec.ts @@ -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 }; diff --git a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts index f642e7aca03..61bc9f96e8b 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts @@ -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 }) }; " `); }); @@ -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 }) }; " `); }); @@ -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 () => {