Skip to content

Relay 21 drops @defer chunks whose subPath addresses a leaf inside the fragment (per-item non-Node lists, deduped fields, connection edges) #5371

Description

@jonreading81

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.

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