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
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