diff --git a/packages/relay-test-utils/__tests__/RelayMockPayloadGenerator-test.js b/packages/relay-test-utils/__tests__/RelayMockPayloadGenerator-test.js index 913c0d147e16..56699f4fe8e1 100644 --- a/packages/relay-test-utils/__tests__/RelayMockPayloadGenerator-test.js +++ b/packages/relay-test-utils/__tests__/RelayMockPayloadGenerator-test.js @@ -2201,3 +2201,210 @@ describe("Aliased linked fields without arguments don't overwrite each other", ( ); }); }); + +describe('Plural linked field selected on both a concrete selection and an abstract InlineFragment at the same level (#5258)', () => { + // Regression test for facebook/relay#5258 — currently demonstrates buggy + // behavior; flip assertion when the underlying bug is fixed. + // + // Scenario from the issue: a query field returns a concrete type that + // implements an interface, and the same plural linked field is selected + // both directly (concrete path, returning the concrete element type) and + // via a fragment spread on the interface (abstract path, returning the + // abstract element type). Because the field's return type differs across + // the two paths, the Relay compiler does NOT dedupe the selections — the + // normalization AST keeps the concrete `LinkedField` plus an abstract + // `InlineFragment` that carries its own copy of the same `LinkedField`. + // + // RelayMockPayloadGenerator's `_traverseSelections` processes both + // selections sequentially. On the second pass, `_mockLink` reads the + // already-generated array out of `data[applicationName]` and passes the + // whole array as `prevData` to each new element generated by + // `generateMockList`. The result is `[[e1,e2,e3], [e1,e2,e3], ...]` + // instead of `[e1, e2, e3]`. + // + // Scope: this test only covers the array-shape symptom. The related + // `mockData[TYPENAME_KEY] = selection.type` symptom from the issue's + // "Root Cause Analysis" (point #3 — abstract type forced onto already- + // generated concrete data) is a separate symptom and is not asserted + // here. + // + // The OSS test schema does not naturally model a concrete-vs-abstract + // return-type divergence on the same field name (every plural field + // returning an interface returns the same `[Actor]` from both the + // interface and its implementers, so the compiler dedupes them — a + // schema-extension prototype with a divergent `[ConcreteEdge]` vs + // `[AbstractEdge]` interface implementation was tried and the compiler + // still folded the InlineFragment because the parent field's static + // type pinned the abstract narrowing). To mirror the issue's + // "Standalone Reproduction Script" exactly, this test hand-constructs + // a `ConcreteRequest` matching the AST shape from the issue body. + // `MockPayloadGenerator.generate` walks the AST and does not consult a + // schema, so the type names below need not exist in the test schema. + test('BUG: produces nested-array shape instead of a flat array (#5258)', () => { + const request: $FlowFixMe = { + kind: 'Request', + fragment: { + kind: 'Fragment', + name: 'RelayMockPayloadGeneratorTest5258Query', + type: 'Query', + metadata: null, + argumentDefinitions: [], + selections: [ + { + kind: 'LinkedField', + alias: null, + name: 'items', + storageKey: null, + args: null, + concreteType: 'ConcreteConnection', + plural: false, + selections: [ + { + kind: 'LinkedField', + alias: null, + name: 'edges', + storageKey: null, + args: null, + concreteType: 'ConcreteEdge', + plural: true, + selections: [ + { + kind: 'ScalarField', + alias: null, + name: 'cursor', + args: null, + storageKey: null, + }, + ], + }, + ], + }, + ], + }, + operation: { + kind: 'Operation', + name: 'RelayMockPayloadGeneratorTest5258Query', + argumentDefinitions: [], + selections: [ + { + kind: 'LinkedField', + alias: null, + name: 'items', + storageKey: null, + args: null, + concreteType: 'ConcreteConnection', + plural: false, + selections: [ + { + kind: 'LinkedField', + alias: null, + name: 'edges', + storageKey: null, + args: null, + concreteType: 'ConcreteEdge', + plural: true, + selections: [ + { + kind: 'ScalarField', + alias: null, + name: '__typename', + args: null, + storageKey: null, + }, + { + kind: 'ScalarField', + alias: null, + name: 'cursor', + args: null, + storageKey: null, + }, + ], + }, + { + kind: 'InlineFragment', + type: 'AbstractConnection', + abstractKey: '__isAbstractConnection', + selections: [ + { + kind: 'LinkedField', + alias: null, + name: 'edges', + storageKey: null, + args: null, + concreteType: null, + plural: true, + selections: [ + { + kind: 'ScalarField', + alias: null, + name: '__typename', + args: null, + storageKey: null, + }, + { + kind: 'ScalarField', + alias: null, + name: 'cursor', + args: null, + storageKey: null, + }, + ], + }, + ], + }, + ], + }, + ], + }, + params: { + cacheID: 'relay-mock-payload-generator-test-5258', + id: null, + metadata: {}, + name: 'RelayMockPayloadGeneratorTest5258Query', + operationKind: 'query', + text: null, + }, + }; + const operation = createOperationDescriptor(request, {}); + const payload = RelayMockPayloadGenerator.generate(operation, { + ConcreteConnection: () => ({ + edges: [ + {__typename: 'ConcreteEdge', cursor: 'a'}, + {__typename: 'ConcreteEdge', cursor: 'b'}, + {__typename: 'ConcreteEdge', cursor: 'c'}, + ], + }), + }); + // $FlowFixMe[unclear-type] payload.data is intentionally untyped here + const edges = (payload.data: any).items.edges; + expect(Array.isArray(edges)).toBe(true); + expect(edges).toHaveLength(3); + // BUG: each entry is the entire previous edges array (with extra + // `__typename`/`cursor` properties bolted onto the Array instance + // from the abstract InlineFragment pass) instead of a single edge + // object. The exact buggy shape is asserted with a strict structural + // match so any "improvement" (nested arrays collapsing partially, + // elements becoming single edges wrapped in an array, missing + // properties, etc.) breaks this test loudly. When the bug is fixed, + // replace the three deep assertions with the flat shape: + // expect(edges[0]).toEqual({__typename: 'ConcreteEdge', cursor: 'a'}); + // expect(edges[1]).toEqual({__typename: 'ConcreteEdge', cursor: 'b'}); + // expect(edges[2]).toEqual({__typename: 'ConcreteEdge', cursor: 'c'}); + const expectedBuggyEdges = [ + {__typename: 'ConcreteEdge', cursor: 'a'}, + {__typename: 'ConcreteEdge', cursor: 'b'}, + {__typename: 'ConcreteEdge', cursor: 'c'}, + ]; + for (let i = 0; i < 3; i++) { + expect(Array.isArray(edges[i])).toBe(true); + expect(edges[i]).toHaveLength(3); + // The whole edges array gets repeated as each element. + expect(Array.from(edges[i])).toEqual(expectedBuggyEdges); + // Buggy second-pass also bolts the abstract path's own scalar + // fields onto the Array instance (taking the first generated + // edge's values). + expect(edges[i].__typename).toBe('ConcreteEdge'); + expect(edges[i].cursor).toBe('a'); + } + }); +});