Version
react-relay / relay-runtime 21.0.1, with React 19 streaming SSR + @defer.
Problem
Under React 19 streaming SSR of a query with @defer, useFragment reads partial data instead of suspending, while the underlying OperationExecutor is still emitting incremental payloads. Consumers that expect the fragment's fields to be either fully present (fragment resolved) or absent (fragment suspended) crash on undefined subtree fields.
Fix proposed in #5360.
Root cause
fetchQueryDeduped caches a per-identifier subject and tears it down when subscriber count hits 0. Under React 19 streaming SSR, that cleanup fires between Suspense render passes for a query still emitting @defer chunks — the subject is torn down while the executor is alive. From that moment on:
getPromiseForActiveRequest returns null (the cache entry has been deleted).
RelayOperationTracker.getPendingOperationsAffectingOwner returns null (no other operation writes to the owner's records — this is the specifically-broken case when a sibling non-deferred query has already populated the shared record).
getPendingOperationsForFragment returns null.
useFragment reads the partial snapshot and returns non-suspended data.
The condition surfaces most obviously when a sibling non-deferred query has already populated the shared record — viewer.me exists, but viewer.me.commentsReceived (owned by the deferred fragment) does not — because that's the state where partial-data reads look "consistent enough" to render but crash on a specific field. Structurally, the underlying gap (request-cache drained between Suspense passes) affects any streaming operation whose executor outlives its cache subject.
Minimal shape
Two queries preloaded on the same page.
Query A (non-deferred, populates viewer.me in the store):
query SiblingQuery {
viewer {
me {
username
hasProFeatures
}
}
}
Query B (defers a fragment that reads other fields on the same viewer.me):
query MyDashboardQuery($itemCount: Int!) {
viewer {
...LatestCommentsList_viewer @defer @arguments(itemCount: $itemCount)
}
}
fragment LatestCommentsList_viewer on Viewer @argumentDefinitions(itemCount: {type: "Int!"}) {
me {
commentsReceived(first: $itemCount) {
edges { ...CommentCardList_commentEdges }
}
}
}
SiblingQuery completes first, populating viewer.me. During SSR of the consumer of LatestCommentsList_viewer:
viewer → present
viewer.me → present (from SiblingQuery)
viewer.me.commentsReceived → undefined (still in the deferred chunk of MyDashboardQuery)
useFragment returns { me: { …siblingFields }, commentsReceived: undefined } instead of suspending.
Repro fingerprint
Switched to client rendering because the server rendering errored:
TypeError: Cannot read properties of undefined (reading 'edges')
at LatestCommentsList (…/index.jsx:59:34)
Expected behaviour
useFragment for a fragment whose data has not yet arrived (spread was @defer'd, no deferred chunk applied) should suspend regardless of whether the parent record identity exists in the store from another query. Otherwise every consumer must defensively null-guard every field their fragment selects, defeating the point of generated non-null types.
Fix
#5360 — introduces a per-environment registry of operation completion promises tied to OperationExecutor lifetime, independent of the request-cache subject and its subscriber count. getPendingOperationsForFragment consults it as a last-resort correlation. Gated behind RelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION (default off).
Related
#5354 — distinct-but-related: @defer on a fragment-nested spread that uses @connection leaves the connection's edges empty.
Version
react-relay/relay-runtime21.0.1, with React 19 streaming SSR +@defer.Problem
Under React 19 streaming SSR of a query with
@defer,useFragmentreads partial data instead of suspending, while the underlyingOperationExecutoris still emitting incremental payloads. Consumers that expect the fragment's fields to be either fully present (fragment resolved) or absent (fragment suspended) crash on undefined subtree fields.Fix proposed in #5360.
Root cause
fetchQueryDedupedcaches a per-identifier subject and tears it down when subscriber count hits 0. Under React 19 streaming SSR, that cleanup fires between Suspense render passes for a query still emitting@deferchunks — the subject is torn down while the executor is alive. From that moment on:getPromiseForActiveRequestreturnsnull(the cache entry has been deleted).RelayOperationTracker.getPendingOperationsAffectingOwnerreturnsnull(no other operation writes to the owner's records — this is the specifically-broken case when a sibling non-deferred query has already populated the shared record).getPendingOperationsForFragmentreturnsnull.useFragmentreads the partial snapshot and returns non-suspended data.The condition surfaces most obviously when a sibling non-deferred query has already populated the shared record —
viewer.meexists, butviewer.me.commentsReceived(owned by the deferred fragment) does not — because that's the state where partial-data reads look "consistent enough" to render but crash on a specific field. Structurally, the underlying gap (request-cache drained between Suspense passes) affects any streaming operation whose executor outlives its cache subject.Minimal shape
Two queries preloaded on the same page.
Query A (non-deferred, populates
viewer.mein the store):Query B (defers a fragment that reads other fields on the same
viewer.me):SiblingQuerycompletes first, populatingviewer.me. During SSR of the consumer ofLatestCommentsList_viewer:viewer→ presentviewer.me→ present (fromSiblingQuery)viewer.me.commentsReceived→ undefined (still in the deferred chunk ofMyDashboardQuery)useFragmentreturns{ me: { …siblingFields }, commentsReceived: undefined }instead of suspending.Repro fingerprint
Expected behaviour
useFragmentfor a fragment whose data has not yet arrived (spread was@defer'd, no deferred chunk applied) should suspend regardless of whether the parent record identity exists in the store from another query. Otherwise every consumer must defensively null-guard every field their fragment selects, defeating the point of generated non-null types.Fix
#5360 — introduces a per-environment registry of operation completion promises tied to
OperationExecutorlifetime, independent of the request-cache subject and its subscriber count.getPendingOperationsForFragmentconsults it as a last-resort correlation. Gated behindRelayFeatureFlags.ENABLE_IN_FLIGHT_OPERATION_CORRELATION(default off).Related
#5354 — distinct-but-related:
@deferon a fragment-nested spread that uses@connectionleaves the connection'sedgesempty.