diff --git a/.changeset/brown-books-build.md b/.changeset/brown-books-build.md new file mode 100644 index 00000000000..965bfdd482c --- /dev/null +++ b/.changeset/brown-books-build.md @@ -0,0 +1,7 @@ +--- +'@graphql-codegen/visitor-plugin-common': patch +'@graphql-codegen/typescript-operations': patch +--- + +handles conditional spread of a fragment whose top-level selections are fragment spreads or contain +inline fragments 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 c5d463b0e32..6ab411bb62b 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 @@ -571,7 +571,36 @@ export class SelectionSetToObject< [], ); - collectGrouped(flattenedSelectionNodes); + // Top-level INLINE_FRAGMENT / FRAGMENT_SPREAD nodes among the + // inlined selections cannot be consumed directly by + // `buildSelectionSet`, which only handles FIELD and DIRECTIVE + // kinds for AST nodes and throws "Unexpected type." otherwise. + // Route them through `flattenSelectionSet` — which already + // expands inline fragments and fragment spreads per type — and + // merge the FIELD-only result with the already-FIELD selections + // before handing off. + const directNodes: GroupedTypeNameNode[] = []; + const nestedSelections: SelectionNode[] = []; + for (const n of flattenedSelectionNodes) { + if ( + 'kind' in n && + (n.kind === Kind.INLINE_FRAGMENT || n.kind === Kind.FRAGMENT_SPREAD) + ) { + nestedSelections.push(n); + } else { + directNodes.push(n); + } + } + if (nestedSelections.length) { + const { selectionNodesByTypeName: nestedByType } = this.flattenSelectionSet( + nestedSelections, + schemaType, + ); + const nestedForThisType = nestedByType.get(typeName) ?? []; + directNodes.push(...nestedForThisType); + } + + collectGrouped(directNodes); } if (incrementalDirectivesFound) { 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 605758badf6..16b88ad79a6 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 @@ -604,6 +604,125 @@ describe('TypeScript Operations Plugin - @include directives', () => { " `); }); + + it('handles conditional spread of a fragment whose top-level selections contain inline fragments', async () => { + // Regression: when the spread fragment's top-level selection set contains + // an INLINE_FRAGMENT (or nested FRAGMENT_SPREAD), the conditional path used + // to push those raw AST nodes into `buildSelectionSet`, which only accepts + // FIELD/DIRECTIVE for raw nodes and threw `TypeError: Unexpected type.`. + const testSchema = buildSchema(/* GraphQL */ ` + type Book { + id: ID! + title: String! + } + type Magazine { + id: ID! + issue: Int! + } + union Publication = Book | Magazine + type Library { + id: ID! + name: String! + featured: Publication + } + type Query { + library(id: ID!): Library + } + `); + + const document = parse(/* GraphQL */ ` + fragment PublicationFragment on Publication { + ... on Book { + id + title + } + ... on Magazine { + id + issue + } + } + + query Library($includeFeatured: Boolean!) { + library(id: "x") { + id + name + featured { + ...PublicationFragment @include(if: $includeFeatured) + } + } + } + `); + + const { content } = await plugin( + testSchema, + [{ location: '', document }], + {}, + { outputFile: 'graphql.ts' }, + ); + + // Should not throw, and should produce a usable type where the Publication + // fields appear when includeFeatured=true and an empty-object variant + // covers includeFeatured=false. + expect(content).toContain('LibraryQuery'); + expect(content).toContain('featured'); + }); + + it('handles conditional spread of a fragment whose top-level selections are fragment spreads', async () => { + // Same regression, but the inline fragments inside the spread fragment have + // been refactored into named fragment spreads — also failed before the fix. + const testSchema = buildSchema(/* GraphQL */ ` + type Book { + id: ID! + title: String! + } + type Magazine { + id: ID! + issue: Int! + } + union Publication = Book | Magazine + type Library { + id: ID! + featured: Publication + } + type Query { + library(id: ID!): Library + } + `); + + const document = parse(/* GraphQL */ ` + fragment BookFragment on Book { + id + title + } + fragment MagazineFragment on Magazine { + id + issue + } + fragment PublicationFragment on Publication { + ...BookFragment + ...MagazineFragment + } + + query Library($includeFeatured: Boolean!) { + library(id: "x") { + id + featured { + ...PublicationFragment @include(if: $includeFeatured) + } + } + } + `); + + const { content } = await plugin( + testSchema, + [{ location: '', document }], + {}, + { outputFile: 'graphql.ts' }, + ); + + expect(content).toContain('LibraryQuery'); + expect(content).toContain('featured'); + }); }); describe('TypeScript Operations Plugin - @skip directive', () => {