Skip to content

MockPayloadGenerator produces nested arrays when a concrete type's InlineFragment contains the same plural LinkedField as an abstract InlineFragment at the same level #5258

Description

@Sakshi-Saraswat-1

MockPayloadGenerator.generate() produces invalid nested arrays ([[e1,e2,e3], [e1,e2,e3], [e1,e2,e3]] instead of [e1, e2, e3]) when the normalization AST has two InlineFragment selections at the same level — one concrete, one abstract — that both contain the same plural LinkedField (e.g., edges).

This happens when a GraphQL field returns a concrete type that implements an interface, and a fragment on that interface is spread inside the query. The Relay compiler correctly generates both InlineFragments in the normalization AST, and RelayResponseNormalizer handles them correctly at runtime. However, MockPayloadGenerator processes both InlineFragments sequentially and corrupts the data on the second pass.

Relay version

relay-test-utils (tested on the version bundled with our project — behavior traced in RelayMockPayloadGenerator.js)

Minimal Reproduction

Schema:

interface AbstractConnection {
edges: [AbstractEdge]
}

interface AbstractEdge {
cursor: String
}

type ConcreteConnection implements AbstractConnection {
edges: [ConcreteEdge]
}

type ConcreteEdge implements AbstractEdge {
cursor: String
node: Item
}

type Item {
id: ID!
}

type Query {
items: ConcreteConnection
}

Query:

query TestQuery {
items {
edges { cursor }
...MyFragment
}
}

fragment MyFragment on AbstractConnection {
edges { cursor }
}

What the Relay compiler generates (normalization AST):

{
concreteType: "ConcreteConnection",
name: "items",
selections: [
// Selection 1: Concrete edges
{
concreteType: "ConcreteEdge",
name: "edges",
plural: true,
selections: [{ name: "cursor" }, { name: "__typename" }]
},
// Selection 2: Abstract InlineFragment (from ...MyFragment)
{
kind: "InlineFragment",
type: "AbstractConnection",
abstractKey: "__isAbstractConnection",
selections: [{
name: "edges",
plural: true,
concreteType: null, // No concrete type on abstract path
selections: [{ name: "cursor" }, { name: "__typename" }]
}]
}
]
}

Test code:

const payload = MockPayloadGenerator.generate(operation, {
ConcreteConnection: () => ({
edges: [
{ __typename: 'ConcreteEdge', cursor: 'a' },
{ __typename: 'ConcreteEdge', cursor: 'b' },
{ __typename: 'ConcreteEdge', cursor: 'c' },
],
}),
});

const edges = payload.data.items.edges;

Expected:

edges = [
{ __typename: "ConcreteEdge", cursor: "a" },
{ __typename: "ConcreteEdge", cursor: "b" },
{ __typename: "ConcreteEdge", cursor: "c" }
]

Actual:

edges = [
[{ __typename: "ConcreteEdge", cursor: "a" }, { __typename: "ConcreteEdge", cursor: "b" }, { __typename: "ConcreteEdge", cursor: "c" }],
[{ __typename: "ConcreteEdge", cursor: "a" }, { __typename: "ConcreteEdge", cursor: "b" }, { __typename: "ConcreteEdge", cursor: "c" }],
[{ __typename: "ConcreteEdge", cursor: "a" }, { __typename: "ConcreteEdge", cursor: "b" }, { __typename: "ConcreteEdge", cursor: "c" }]
]

Each edges[i] is the entire previous array instead of a single edge object.

Standalone Reproduction Script

const { MockPayloadGenerator } = require('relay-test-utils');
const { createOperationDescriptor } = require('relay-runtime');

const operation = createOperationDescriptor({
kind: 'Request',
fragment: {
kind: 'Fragment', name: 'BugReproQuery', type: 'Query',
argumentDefinitions: [],
selections: [{
kind: 'LinkedField', name: 'items', alias: null, plural: false,
concreteType: 'ConcreteConnection',
selections: [{
kind: 'LinkedField', name: 'edges', alias: null, plural: true,
concreteType: 'ConcreteEdge',
selections: [
{ kind: 'ScalarField', name: '__typename', alias: null },
{ kind: 'ScalarField', name: 'cursor', alias: null },
],
}],
}],
},
operation: {
kind: 'Operation', name: 'BugReproQuery', argumentDefinitions: [],
selections: [{
kind: 'LinkedField', name: 'items', alias: null, plural: false,
concreteType: 'ConcreteConnection',
selections: [
{
kind: 'LinkedField', name: 'edges', alias: null, plural: true,
concreteType: 'ConcreteEdge',
selections: [
{ kind: 'ScalarField', name: '__typename', alias: null },
{ kind: 'ScalarField', name: 'cursor', alias: null },
],
},
{
kind: 'InlineFragment', type: 'AbstractConnection',
abstractKey: '__isAbstractConnection',
selections: [{
kind: 'LinkedField', name: 'edges', alias: null, plural: true,
concreteType: null,
selections: [
{ kind: 'ScalarField', name: '__typename', alias: null },
{ kind: 'ScalarField', name: 'cursor', alias: null },
],
}],
},
],
}],
},
params: {
cacheID: 'bug-repro', id: null,
metadata: {
relayTestingSelectionTypeInfo: {
items: { type: 'ConcreteConnection', plural: false },
'items.edges': { type: 'AbstractEdge', plural: true },
'items.edges.cursor': { type: 'String', plural: false },
},
},
name: 'BugReproQuery', operationKind: 'query',
text: 'query BugReproQuery { items { edges { __typename cursor } } }',
},
}, {});

const payload = MockPayloadGenerator.generate(operation, {
ConcreteConnection: () => ({
edges: [
{ __typename: 'ConcreteEdge', cursor: 'a' },
{ __typename: 'ConcreteEdge', cursor: 'b' },
{ __typename: 'ConcreteEdge', cursor: 'c' },
],
}),
});

const edges = payload.data.items.edges;
console.log('edges[0] is array?', Array.isArray(edges[0]));
// Output: true — BUG

Root Cause Analysis

We traced the issue to three behaviors in RelayMockPayloadGenerator.js:

Sequential processing of all selections (_traverseSelections)

All selections (including multiple InlineFragments) are processed in a forEach loop:

selections.forEach(function(selection) {
mockData = _this._mockLink(selection, path, mockData, ...);
});

Both the concrete and abstract InlineFragments are processed sequentially. mockData accumulates across both.

Plural field duplication (_mockLink)

When _mockLink processes the edges field the second time (abstract path), it reads the result from the first time:

// data['edges'] is [e1, e2, e3] from the first pass
typeof data[applicationName] === 'object' ? data[applicationName] : null

The entire array [e1, e2, e3] is passed as prevData to EACH new element in generateMockList. Each element starts with the whole previous array as its base, producing [[e1,e2,e3], [e1,e2,e3], [e1,e2,e3]].

__typename forced to abstract type

After processing an abstract InlineFragment, the generator forces __typename to the abstract type:

mockData[TYPENAME_KEY] = selection.type; // Forces "AbstractConnection" even if resolver returned "ConcreteConnection"

This overrides any resolver-provided __typename, so the generated data has abstract typenames that RelayResponseNormalizer cannot resolve.

Impact

This pattern (concrete field + abstract fragment) is common when using GraphQL interfaces. In our codebase, changing Relay fragments from concrete types to abstract interface types (to support multiple implementations) made all unit tests using MockPayloadGenerator fail, while production and integration tests continue to work correctly.

We have ~600+ tests affected and are using workarounds:

Providing edges: [] to prevent edge generation through the abstract path

Bypassing MockPayloadGenerator entirely with raw response data

Post-processing the generated payload to fix typenames and nested arrays

Suggested Fix

One possible fix in _mockLink would be to check if a plural linked field already has data from a previous pass and skip regeneration:

// In _mockLink, before generating plural field data:
if (field.plural && Array.isArray(data[applicationName]) && data[applicationName].length > 0) {
// Data already generated by a previous InlineFragment selection — skip
return data;
}

Alternatively, for the __typename issue, the generator could preserve resolver-provided typenames for abstract InlineFragments instead of overriding them.

Labels

bug, relay-test-utils, MockPayloadGenerator

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions