Summary
OperationExecutor._processIncrementalResponses looks up placeholders by exact (label, chunk.path.join('.')). Placeholders register at pendingPart.path — the fragment-spread path — with no subPath suffix. Any chunk arriving from a spec-compliant incremental-delivery server that carries a subPath (e.g. graphql-core 3.3+'s per-item non-Node list chunks, or field-dedup chunks addressed at a sub-record) therefore lands in a map bucket no placeholder ever registers at → buffered forever → silently dropped by the client store.
Repro shapes
All three are the same underlying lookup miss:
1) String-only subPath (deduplicated single sub-record)
Chunk carries data: {isBranded: false}, subPath: ['owner'] because the server dedupes fields the parent selection already covered.
Chunk arrives at path = ['cloudcast', 'owner']. Placeholder for CreatorActions_cloudcast @defer sits at pathKey = 'cloudcast'. Lookup misses. Field never lands in the owner record.
2) Numeric-in-subPath (non-Node list per-item chunk)
fragment Body on Cloudcast { tags { ..., position } ...ChartPosition_cloudcast @defer } where ChartPosition_cloudcast also selects tags { ..., bestPosition } and CloudcastTag has no id. graphql-core streams per item:
{data: {bestPosition: 1}, subPath: ['tags', 0]}
{data: {bestPosition: 1}, subPath: ['tags', 1]} …
Every chunk arrives at path = ['cloudcast', 'tags', N]. Placeholder sits at ['cloudcast']. Every chunk dropped. Card renders "Global chart history" header + 0 positions.
3) Numeric-in-subPath through a connection
fragment Sidebar on User { ...SideBarSpotlights_user @defer } where SideBarSpotlights_user selects spotlight.users(first: 4) @connection. Edges arrive at path = ['user', 'spotlight', 'users', 'edges', N, 'node', ...]. Placeholder sits at ['user']. Every edge dropped. Header reads "Spotlights (10)" (scalar sibling made it through the pre-existing paths) but the 4 rows are blank.
Case #3 is the @defer + @connection symptom (empty edges + count present); case #1 is the "deferred field never lands" symptom; case #2 is the non-Node array symptom.
The bug is one line
_processIncrementalResponses, packages/relay-runtime/store/OperationExecutor.js:
const pathKey = path.map(String).join('.');
let resultForPath = resultForLabel.get(pathKey);
Exact match only. There's no logic that says "if the chunk's path is longer than any placeholder's path, walk back."
Fix (PR)
Filed as #5370.
- Prefix-match placeholder lookup — walk shorter prefixes of the chunk's path until a placeholder is found; compute
subPath = path.slice(prefix.length).
- Shape-specific recovery in
_processDeferResponse:
- String-only subPath → wrap data + normalise at placeholder root, seed the local source with a clone of the existing parent record so
_normalizeLink's id-fallback re-uses the store's real linked-record IDs (avoids synthesising a client ID that would sever the parent's link on publish).
- Numeric-in-subPath → walk the fragment's normalization AST alongside the store from
parentID following each subPath key, resolve the child dataID + item-level selection + concrete type, and normalise chunk data directly INTO that child record (no plural-link clobber).
normalizeResponse gains an optional existingRootRecord parameter; all callers unchanged unless they need it.
yarn typecheck clean; 78/78 existing defer + deferredStreamedConnection tests still pass; verified empirically against a graphql-core 3.3+ backend in a shipping app across all three shapes.
Context
Supersedes #5354 (closed) — same root cause, three distinct symptom shapes, one fix for all of them. The prior issue framed the two visible symptoms (fragment-nested @connection empty edges; non-Node array patches dropped) as separate bugs. They aren't. Opening this to link the PR to a clear description of the unified root cause.
Summary
OperationExecutor._processIncrementalResponseslooks up placeholders by exact(label, chunk.path.join('.')). Placeholders register atpendingPart.path— the fragment-spread path — with nosubPathsuffix. Any chunk arriving from a spec-compliant incremental-delivery server that carries asubPath(e.g. graphql-core 3.3+'s per-item non-Node list chunks, or field-dedup chunks addressed at a sub-record) therefore lands in a map bucket no placeholder ever registers at → buffered forever → silently dropped by the client store.Repro shapes
All three are the same underlying lookup miss:
1) String-only subPath (deduplicated single sub-record)
Chunk carries
data: {isBranded: false}, subPath: ['owner']because the server dedupes fields the parent selection already covered.Chunk arrives at
path = ['cloudcast', 'owner']. Placeholder forCreatorActions_cloudcast @defersits atpathKey = 'cloudcast'. Lookup misses. Field never lands in the owner record.2) Numeric-in-subPath (non-Node list per-item chunk)
fragment Body on Cloudcast { tags { ..., position } ...ChartPosition_cloudcast @defer }whereChartPosition_cloudcastalso selectstags { ..., bestPosition }andCloudcastTaghas noid. graphql-core streams per item:{data: {bestPosition: 1}, subPath: ['tags', 0]}{data: {bestPosition: 1}, subPath: ['tags', 1]}…Every chunk arrives at
path = ['cloudcast', 'tags', N]. Placeholder sits at['cloudcast']. Every chunk dropped. Card renders "Global chart history" header + 0 positions.3) Numeric-in-subPath through a connection
fragment Sidebar on User { ...SideBarSpotlights_user @defer }whereSideBarSpotlights_userselectsspotlight.users(first: 4) @connection. Edges arrive atpath = ['user', 'spotlight', 'users', 'edges', N, 'node', ...]. Placeholder sits at['user']. Every edge dropped. Header reads "Spotlights (10)" (scalar sibling made it through the pre-existing paths) but the 4 rows are blank.Case #3 is the
@defer + @connectionsymptom (empty edges + count present); case #1 is the "deferred field never lands" symptom; case #2 is the non-Node array symptom.The bug is one line
_processIncrementalResponses,packages/relay-runtime/store/OperationExecutor.js:Exact match only. There's no logic that says "if the chunk's path is longer than any placeholder's path, walk back."
Fix (PR)
Filed as #5370.
subPath = path.slice(prefix.length)._processDeferResponse:_normalizeLink's id-fallback re-uses the store's real linked-record IDs (avoids synthesising a client ID that would sever the parent's link on publish).parentIDfollowing each subPath key, resolve the child dataID + item-level selection + concrete type, and normalise chunk data directly INTO that child record (no plural-link clobber).normalizeResponsegains an optionalexistingRootRecordparameter; all callers unchanged unless they need it.yarn typecheckclean; 78/78 existing defer + deferredStreamedConnection tests still pass; verified empirically against a graphql-core 3.3+ backend in a shipping app across all three shapes.Context
Supersedes #5354 (closed) — same root cause, three distinct symptom shapes, one fix for all of them. The prior issue framed the two visible symptoms (fragment-nested
@connectionempty edges; non-Node array patches dropped) as separate bugs. They aren't. Opening this to link the PR to a clear description of the unified root cause.