Skip to content
Open
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 `@defer` fields optional: expand overlapping named `@defer` spreads into per-selection unions, preserve optionality on interface/union selections, surface nested `@defer`/`@stream` through inlined fragment spreads, and restore trailing `_` on implementing-type aliases when `omitOperationSuffix` is enabled
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ export class SelectionSetToObject<
isInterfaceType(typeOnSchema) &&
parentType.getInterfaces().includes(typeOnSchema)
) {
this._appendToTypeMap(types, parentType.name, fields);
// Same as object-type path: keep inline-fragment directives on fields so
// @defer/@skip/@include survive the later `fragmentDirectives` filter.
this._appendToTypeMap(types, parentType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, parentType.name, spreadsUsage[parentType.name]);
this._collectInlineFragments(typeOnSchema, inlines, types);
}
Expand All @@ -202,17 +204,22 @@ export class SelectionSetToObject<
: parentType;
const { fields, inlines, spreads } = separateSelectionSet(node.selectionSet.selections);
const spreadsUsage = this.buildFragmentSpreadsUsage(spreads);
const directives = (node.directives as DirectiveNode[]) || undefined;
const fieldsWithFragmentDirectives: EnrichedFieldNode[] = fields.map(field => ({
...field,
fragmentDirectives: directives,
}));

if (
isObjectType(schemaType) &&
possibleTypes.find(possibleType => possibleType.name === schemaType.name)
) {
this._appendToTypeMap(types, schemaType.name, fields);
this._appendToTypeMap(types, schemaType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, schemaType.name, spreadsUsage[schemaType.name]);
this._collectInlineFragments(schemaType, inlines, types);
} else if (isInterfaceType(schemaType) && schemaType.name === parentType.name) {
for (const possibleType of possibleTypes) {
this._appendToTypeMap(types, possibleType.name, fields);
this._appendToTypeMap(types, possibleType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, possibleType.name, spreadsUsage[possibleType.name]);
this._collectInlineFragments(schemaType, inlines, types);
}
Expand All @@ -228,7 +235,7 @@ export class SelectionSetToObject<
// the field should only be added to the valid selections
// in case the possible type actually implements the given interface
if (isTypeSubTypeOf(this._schema, possibleType, fragmentSpreadType)) {
this._appendToTypeMap(types, possibleType.name, fields);
this._appendToTypeMap(types, possibleType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, possibleType.name, spreadsUsage[possibleType.name]);
}
}
Expand All @@ -243,12 +250,17 @@ export class SelectionSetToObject<
: parentType;
const { fields, inlines, spreads } = separateSelectionSet(node.selectionSet.selections);
const spreadsUsage = this.buildFragmentSpreadsUsage(spreads);
const directives = (node.directives as DirectiveNode[]) || undefined;
const fieldsWithFragmentDirectives: EnrichedFieldNode[] = fields.map(field => ({
...field,
fragmentDirectives: directives,
}));

if (
isObjectType(schemaType) &&
possibleTypes.find(possibleType => possibleType.name === schemaType.name)
) {
this._appendToTypeMap(types, schemaType.name, fields);
this._appendToTypeMap(types, schemaType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, schemaType.name, spreadsUsage[schemaType.name]);
this._collectInlineFragments(schemaType, inlines, types);
} else if (isInterfaceType(schemaType)) {
Expand All @@ -260,14 +272,14 @@ export class SelectionSetToObject<
possibleInterfaceType => possibleInterfaceType.name === possibleType.name,
)
) {
this._appendToTypeMap(types, possibleType.name, fields);
this._appendToTypeMap(types, possibleType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, possibleType.name, spreadsUsage[possibleType.name]);
this._collectInlineFragments(schemaType, inlines, types);
}
}
} else {
for (const possibleType of possibleTypes) {
this._appendToTypeMap(types, possibleType.name, fields);
this._appendToTypeMap(types, possibleType.name, fieldsWithFragmentDirectives);
this._appendToTypeMap(types, possibleType.name, spreadsUsage[possibleType.name]);
}
}
Expand All @@ -282,15 +294,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 +319,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 +363,7 @@ export class SelectionSetToObject<
typeName: usage,
onType: fragmentSpreadObject.onType,
selectionNodes: fragmentSelectionNodes,
fragmentDirectives: [...(spread.directives || [])],
fragmentDirectives,
});
}
}
Expand Down Expand Up @@ -416,6 +460,24 @@ export class SelectionSetToObject<

for (const [typeName, records] of Object.entries(fragmentSpreadsUsage)) {
this._appendToTypeMap(result.selectionNodesByTypeName, typeName, records);
// Nested @defer/@stream inside spread fragments are otherwise dropped when
// `inlineFragmentTypes: 'inline'` re-flattens the spread (only the base map merges).
// Walk nested fragment defs with a visited set so types stay consistent and
// fragment cycles cannot hang codegen. (@skip/@include are handled elsewhere —
// promoting them here duplicates types under extractAllFieldsToTypesCompact.)
const nestedParentType =
this._schema.getType(typeName) ?? parentSchemaType ?? this._parentSchemaType;
for (const record of records) {
if (!('fragmentName' in record)) {
continue;
}
this._promoteNestedIncrementalSelections(
record.selectionNodes,
nestedParentType,
result,
new Set([record.fragmentName]),
);
}
}

// 2. Push conditional inline fragments into the result.selectionNodesByTypeNameConditional
Expand Down Expand Up @@ -462,6 +524,64 @@ export class SelectionSetToObject<
}
}

/**
* Surfaces @defer/@stream selections nested inside inlined fragment spreads onto the
* parent conditional map. `visitedFragmentNames` prevents cycles.
*/
private _promoteNestedIncrementalSelections(
selectionNodes: ReadonlyArray<SelectionNode | EnrichedFieldNode>,
parentType: GraphQLNamedType,
result: ReturnType<typeof this.flattenSelectionSet>,
visitedFragmentNames: Set<string>,
): void {
for (const node of selectionNodes) {
if (!('kind' in node)) {
continue;
}

if (node.kind === Kind.INLINE_FRAGMENT && hasIncrementalDeliveryDirectives(node.directives)) {
const conditionalNodes = new Map<string, Array<GroupedTypeNameNode>>();
this._collectInlineFragments(parentType, [node], conditionalNodes);
result.selectionNodesByTypeNameConditional.push(conditionalNodes);
continue;
}

if (node.kind !== Kind.FRAGMENT_SPREAD) {
continue;
}

if (hasIncrementalDeliveryDirectives(node.directives)) {
const conditionalFragmentSpreadsUsage = this.buildFragmentSpreadsUsage([node]);
for (const [conditionalTypeName, conditionalRecords] of Object.entries(
conditionalFragmentSpreadsUsage,
)) {
const conditionalNodes = new Map<string, Array<GroupedTypeNameNode>>();
this._appendToTypeMap(conditionalNodes, conditionalTypeName, conditionalRecords);
result.selectionNodesByTypeNameConditional.push(conditionalNodes);
}
continue;
}

if (visitedFragmentNames.has(node.name.value)) {
continue;
}
visitedFragmentNames.add(node.name.value);

const nestedFragment = this._loadedFragments.find(lf => lf.name === node.name.value);
if (!nestedFragment) {
continue;
}

const nestedParentType = this._schema.getType(nestedFragment.onType) ?? parentType;
this._promoteNestedIncrementalSelections(
nestedFragment.node.selectionSet.selections,
nestedParentType,
result,
visitedFragmentNames,
);
}
}

protected _buildGroupedSelections(parentName: string): {
grouped: GroupedStringifiedTypes;
dependentTypes: DependentType[];
Expand Down Expand Up @@ -1248,11 +1368,12 @@ export class SelectionSetToObject<
}

protected buildFragmentTypeName(name: string, suffix: string, typeName = ''): string {
const fragmentSuffix =
typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix;
// Keep `_${typeName}_${suffix}` even when `omitOperationSuffix` makes `suffix` ''.
// The intermediate `typeName && suffix ? … : typeName ? …` form dropped the trailing
// `_` on implementing-type aliases (`Foo_Bar` instead of `Foo_Bar_`).
return this._convertName(name, {
useTypesPrefix: true,
suffix: fragmentSuffix,
suffix: typeName ? `_${typeName}_${suffix}` : suffix,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,21 @@ describe('extractAllFieldsToTypes: true', () => {
{ outputFile: '' },
);
expect(content).toMatchInlineSnapshot(`
"type UserFragment_DummyUser = {
"type UserFragment_DummyUser_ = {
__typename: 'DummyUser',
id: string,
joinDate: unknown
};

type UserFragment_ActiveUser = {
type UserFragment_ActiveUser_ = {
__typename: 'ActiveUser',
id: string,
joinDate: unknown
};

export type UserFragment =
| UserFragment_DummyUser
| UserFragment_ActiveUser
| UserFragment_DummyUser_
| UserFragment_ActiveUser_
;

export type MeFragment_ActiveUser_parentUser_DummyUser = {
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
Loading