From c6c9b4aa0cc8fb37c3d38b55d507b8ab8c680618 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Tue, 21 Jul 2026 12:01:33 +0100 Subject: [PATCH 1/6] Land @defer chunks that arrive with a subPath (fixes #5354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Recover with prefix-match placeholder lookup plus shape-specific normalisation: * `_processIncrementalResponses`: when the exact key misses, walk shorter path prefixes until a placeholder is found; compute the delta `subPath = path.slice(prefix.length)` and hand it to `_processDeferResponse`. * `_processDeferResponse` (new `subPath` param): - String-only subPath (single sub-record, e.g. `['owner']`): fold `data` under the subPath keys and normalise at the placeholder's root selector. Seed the local normalisation source with a clone of the existing store record (see `normalizeResponse` change) so `_normalizeLink`'s id-fallback re-uses the store's real linked- record IDs instead of synthesising a client ID that would sever the parent's link on publish. - Numeric-in-subPath (non-Node list per-item, connection edge): the new `walkDeferSubPath` helper walks the fragment's normalization AST alongside the store from `parentID`, following each subPath key (string = LinkedField, number = index into the previous plural link), to resolve the child dataID + item-level selection + concrete type. Normalise chunk data directly INTO that child record — prevents per-item chunks from clobbering the parent's plural link via `setLinkedRecordIDs`. * `normalizeResponse` grows an optional `existingRootRecord` parameter (also added to `NormalizeResponseFunction` in `RelayStoreTypes`). When provided, the local source is seeded with a clone of that record instead of a fresh empty one; existing linked-record IDs are then visible to `_normalizeLink`'s local-record fallback. Fully optional and backward-compatible with every existing caller. Backward-compatible: chunks arriving without a subPath still take the exact-key path unchanged. All 78 pre-existing defer/deferredStreamedConnection tests continue to pass. Fixes: https://github.com/facebook/relay/issues/5354 --- .../relay-runtime/store/OperationExecutor.js | 64 ++++- .../relay-runtime/store/RelayStoreTypes.js | 1 + ...ronment-ExecuteWithDeferAndSubPath-test.js | 234 ++++++++++++++++++ ...erAndSubPathTestAddressFragment.graphql.js | 70 ++++++ ...DeferAndSubPathTestAddressQuery.graphql.js | 173 +++++++++++++ ...ferAndSubPathTestPhonesFragment.graphql.js | 83 +++++++ ...hDeferAndSubPathTestPhonesQuery.graphql.js | 204 +++++++++++++++ .../relay-runtime/store/normalizeResponse.js | 7 +- .../store/resolveDeferSubPathChunk.js | 198 +++++++++++++++ 9 files changed, 1029 insertions(+), 5 deletions(-) create mode 100644 packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery.graphql.js create mode 100644 packages/relay-runtime/store/resolveDeferSubPathChunk.js diff --git a/packages/relay-runtime/store/OperationExecutor.js b/packages/relay-runtime/store/OperationExecutor.js index 4340570563e46..b72d24b162c68 100644 --- a/packages/relay-runtime/store/OperationExecutor.js +++ b/packages/relay-runtime/store/OperationExecutor.js @@ -67,6 +67,7 @@ const { createReaderSelector, } = require('./RelayModernSelector'); const RelayRecordSource = require('./RelayRecordSource'); +const resolveDeferSubPathChunk = require('./resolveDeferSubPathChunk'); const {ROOT_TYPE, TYPENAME_KEY, getStorageKey} = require('./RelayStoreUtils'); const invariant = require('invariant'); const warning = require('warning'); @@ -1361,6 +1362,14 @@ class Executor { if (label.indexOf('$defer$') !== -1) { const pathKey = path.map(String).join('.'); let resultForPath = resultForLabel.get(pathKey); + let deferSubPath: ?ReadonlyArray = null; + if (resultForPath == null || resultForPath.kind !== 'placeholder') { + const prefix = findDeferPlaceholderByPrefix(resultForLabel, path); + if (prefix != null) { + resultForPath = prefix.placeholder; + deferSubPath = prefix.subPath; + } + } if (resultForPath == null) { resultForPath = {kind: 'response', responses: [incrementalResponse]}; resultForLabel.set(pathKey, resultForPath); @@ -1379,7 +1388,13 @@ class Executor { placeholder.kind, ); relayPayloads.push( - this._processDeferResponse(label, path, placeholder, response), + this._processDeferResponse( + label, + path, + placeholder, + response, + deferSubPath, + ), ); } else { // @stream payload path values end in the field name and item index, @@ -1418,15 +1433,38 @@ class Executor { path: ReadonlyArray, placeholder: DeferPlaceholder, response: GraphQLResponseWithData, + subPath?: ?ReadonlyArray, ): RelayResponsePayload { const {dataID: parentID} = placeholder.selector; const prevActorIdentifier = this._actorIdentifier; this._actorIdentifier = placeholder.actorIdentifier ?? this._actorIdentifier; + const activeSubPath = + subPath != null && subPath.length > 0 ? subPath : null; + const recovery = + activeSubPath != null + ? resolveDeferSubPathChunk( + placeholder, + response, + activeSubPath, + this._getStore(this._actorIdentifier).getSource(), + ) + : null; + if (activeSubPath != null && recovery == null) { + this._actorIdentifier = prevActorIdentifier; + return { + errors: response.errors ?? null, + fieldPayloads: [], + followupPayloads: null, + incrementalPlaceholders: null, + isFinal: false, + source: RelayRecordSource.create(), + }; + } const relayPayload = this._normalizeResponse( - response, - placeholder.selector, - placeholder.typeName, + recovery?.response ?? response, + recovery?.selector ?? placeholder.selector, + recovery?.typeName ?? placeholder.typeName, { actorIdentifier: this._actorIdentifier, deferDeduplicatedFields: this._deferDeduplicatedFields, @@ -1437,6 +1475,7 @@ class Executor { treatMissingFieldsAsNull: this._treatMissingFieldsAsNull, }, this._useExecTimeResolvers, + recovery?.existingRootRecord, ); this._getPublishQueueAndSaveActor().commitPayload( this._operation, @@ -1823,6 +1862,23 @@ function validateOptimisticResponsePayload( } } +function findDeferPlaceholderByPrefix( + resultForLabel: Map, + path: ReadonlyArray, +): ?{ + placeholder: IncrementalResults, + subPath: ReadonlyArray, +} { + for (let prefixLen = path.length - 1; prefixLen > 0; prefixLen--) { + const prefixKey = path.slice(0, prefixLen).map(String).join('.'); + const result = resultForLabel.get(prefixKey); + if (result != null && result.kind === 'placeholder') { + return {placeholder: result, subPath: path.slice(prefixLen)}; + } + } + return null; +} + module.exports = { execute, }; diff --git a/packages/relay-runtime/store/RelayStoreTypes.js b/packages/relay-runtime/store/RelayStoreTypes.js index f455df3b86899..52e9b985ef13f 100644 --- a/packages/relay-runtime/store/RelayStoreTypes.js +++ b/packages/relay-runtime/store/RelayStoreTypes.js @@ -1249,6 +1249,7 @@ export type NormalizeResponseFunction = ( typeName: string, options: NormalizationOptions, useExecTimeResolvers: boolean, + existingRootRecord?: ?Record, ) => RelayResponsePayload; /** diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js new file mode 100644 index 0000000000000..d74402c7a73be --- /dev/null +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js @@ -0,0 +1,234 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type {GraphQLResponse} from 'relay-runtime/network/RelayNetworkTypes'; +import type {Sink} from 'relay-runtime/network/RelayObservable'; +import type {Snapshot} from 'relay-runtime/store/RelayStoreTypes'; +import type {RequestParameters} from 'relay-runtime/util/RelayConcreteNode'; +import type { + CacheConfig, + Variables, +} from 'relay-runtime/util/RelayRuntimeTypes'; + +const { + MultiActorEnvironment, + getActorIdentifier, +} = require('relay-runtime/multi-actor-environment'); +const RelayNetwork = require('relay-runtime/network/RelayNetwork'); +const RelayObservable = require('relay-runtime/network/RelayObservable'); +const {graphql} = require('relay-runtime/query/GraphQLTag'); +const RelayModernEnvironment = require('relay-runtime/store/RelayModernEnvironment'); +const { + createOperationDescriptor, +} = require('relay-runtime/store/RelayModernOperationDescriptor'); +const { + createReaderSelector, +} = require('relay-runtime/store/RelayModernSelector'); +const RelayModernStore = require('relay-runtime/store/RelayModernStore'); +const RelayRecordSource = require('relay-runtime/store/RelayRecordSource'); +const {disallowWarnings} = require('relay-test-utils-internal'); + +disallowWarnings(); + +describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( + 'execute() a query with @defer + subPath', + environmentType => { + let callbacks; + let complete; + let dataSource; + let environment; + let error; + let next; + + describe(environmentType, () => { + beforeEach(() => { + complete = jest.fn<[], unknown>(); + error = jest.fn<[Error], unknown>(); + next = jest.fn<[GraphQLResponse], unknown>(); + callbacks = {complete, error, next}; + const fetch = ( + _query: RequestParameters, + _variables: Variables, + _cacheConfig: CacheConfig, + ): RelayObservable => { + return RelayObservable.create( + (sink: Sink) => { + dataSource = sink; + }, + ); + }; + const store = new RelayModernStore(RelayRecordSource.create()); + const multiActorEnvironment = new MultiActorEnvironment({ + createNetworkForActor: _actorID => RelayNetwork.create(fetch), + createStoreForActor: _actorID => store, + }); + environment = + environmentType === 'MultiActorEnvironment' + ? multiActorEnvironment.forActor(getActorIdentifier('actor:1234')) + : new RelayModernEnvironment({ + network: RelayNetwork.create(fetch), + store, + }); + }); + + it('processes a deferred payload whose subPath addresses a nested sub-record', () => { + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + id + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment + @dangerously_unaliased_fixme + @defer(label: "AddressFragment") + } + } + } + `; + const fragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment on User { + address { + city + country + } + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const selector = createReaderSelector( + fragment, + '1', + {}, + operation.request, + ); + const callback = jest.fn<[Snapshot], void>(); + environment.subscribe(environment.lookup(selector), callback); + + environment.execute({operation}).subscribe(callbacks); + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + address: {country: 'US'}, + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + callback.mockClear(); + + dataSource.next({ + data: {city: 'San Francisco', country: 'US'}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$defer$AddressFragment', + path: ['node', 'address'], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(1); + const snapshot = callback.mock.calls[0][0]; + expect(snapshot.isMissingData).toBe(false); + expect(snapshot.data).toEqual({ + address: {city: 'San Francisco', country: 'US'}, + }); + }); + + it('processes deferred per-item payloads addressed via numeric subPath', () => { + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + id + allPhones { + phoneNumber { + displayNumber + } + } + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment + @dangerously_unaliased_fixme + @defer(label: "PhonesFragment") + } + } + } + `; + const fragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment on User { + allPhones { + isVerified + phoneNumber { + displayNumber + } + } + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const selector = createReaderSelector( + fragment, + '1', + {}, + operation.request, + ); + + environment.execute({operation}).subscribe(callbacks); + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + allPhones: [ + {phoneNumber: {displayNumber: '+1-555-0100'}}, + {phoneNumber: {displayNumber: '+1-555-0101'}}, + ], + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + + dataSource.next({ + data: { + isVerified: true, + phoneNumber: {displayNumber: '+1-555-0100'}, + }, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$defer$PhonesFragment', + path: ['node', 'allPhones', 0], + }); + dataSource.next({ + data: { + isVerified: false, + phoneNumber: {displayNumber: '+1-555-0101'}, + }, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$defer$PhonesFragment', + path: ['node', 'allPhones', 1], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + const snapshot = environment.lookup(selector); + expect(snapshot.isMissingData).toBe(false); + expect(snapshot.data).toEqual({ + allPhones: [ + {isVerified: true, phoneNumber: {displayNumber: '+1-555-0100'}}, + {isVerified: false, phoneNumber: {displayNumber: '+1-555-0101'}}, + ], + }); + }); + }); + }, +); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment.graphql.js new file mode 100644 index 0000000000000..86c4cb03212b5 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment.graphql.js @@ -0,0 +1,70 @@ +/** + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$data = { + readonly address: ?{ + readonly city: ?string, + readonly country: ?string, + }, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "StreetAddress", + "kind": "LinkedField", + "name": "address", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "city", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "country", + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "User", + "abstractKey": null +}; + +(node/*:: as any*/).hash = "274e0765e9812990054a75e1b55ba7a2"; + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery.graphql.js new file mode 100644 index 0000000000000..527cd7b41a63c --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery.graphql.js @@ -0,0 +1,173 @@ +/** + * @generated SignedSource<<399fd526d4d897ef162b23f7aa9567b4>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment.graphql"; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$variables = { + id: string, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$data = { + readonly node: ?{ + readonly id?: string, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment$fragmentType, + }, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery = { + response: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$data, + variables: RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$variables, +}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*:: as any*/), + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment" + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Operation", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*:: as any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$defer$AddressFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "StreetAddress", + "kind": "LinkedField", + "name": "address", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "city", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "country", + "storageKey": null + } + ], + "storageKey": null + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "c84e3031b9d6b7dd2abe4e2c5e48a42c", + "id": null, + "metadata": {}, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery", + "operationKind": "query", + "text": "query RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on User {\n id\n ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment @defer(label: \"RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$defer$AddressFragment\")\n }\n id\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressFragment on User {\n address {\n city\n country\n }\n}\n" + } +}; +})(); + +(node/*:: as any*/).hash = "c30cda65cc7c383f034293ef3abf08eb"; + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$variables, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestAddressQuery$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment.graphql.js new file mode 100644 index 0000000000000..331f73b66b754 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment.graphql.js @@ -0,0 +1,83 @@ +/** + * @generated SignedSource<<89dc8dea6c66d9af6c6badac4c20cdb8>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$data = { + readonly allPhones: ?ReadonlyArray, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "isVerified", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "displayNumber", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "User", + "abstractKey": null +}; + +(node/*:: as any*/).hash = "9f85512473f742e9293d095e9b587bc6"; + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery.graphql.js new file mode 100644 index 0000000000000..967b42d99cd7c --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery.graphql.js @@ -0,0 +1,204 @@ +/** + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment.graphql"; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$variables = { + id: string, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$data = { + readonly node: ?{ + readonly allPhones?: ?ReadonlyArray, + readonly id?: string, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment$fragmentType, + }, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery = { + response: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$data, + variables: RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$variables, +}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "displayNumber", + "storageKey": null + } + ], + "storageKey": null +}, +v4 = { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + (v3/*:: as any*/) + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*:: as any*/), + (v4/*:: as any*/), + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment" + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Operation", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*:: as any*/), + { + "kind": "InlineFragment", + "selections": [ + (v4/*:: as any*/), + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$defer$PhonesFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "isVerified", + "storageKey": null + }, + (v3/*:: as any*/) + ], + "storageKey": null + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "b514fc2d60c74585676058a0d76d1dd3", + "id": null, + "metadata": {}, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery", + "operationKind": "query", + "text": "query RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on User {\n id\n allPhones {\n phoneNumber {\n displayNumber\n }\n }\n ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment @defer(label: \"RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$defer$PhonesFragment\")\n }\n id\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesFragment on User {\n allPhones {\n isVerified\n phoneNumber {\n displayNumber\n }\n }\n}\n" + } +}; +})(); + +(node/*:: as any*/).hash = "2a2aa7c8efdecd21271297008fa575a8"; + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$variables, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery$data, +>*/); diff --git a/packages/relay-runtime/store/normalizeResponse.js b/packages/relay-runtime/store/normalizeResponse.js index 0b6b736803f7f..d6f350226e778 100644 --- a/packages/relay-runtime/store/normalizeResponse.js +++ b/packages/relay-runtime/store/normalizeResponse.js @@ -15,6 +15,7 @@ import type {GraphQLResponseWithData} from '../network/RelayNetworkTypes'; import type {NormalizationOptions} from './RelayResponseNormalizer'; import type { NormalizationSelector, + Record, RelayResponsePayload, } from './RelayStoreTypes'; @@ -28,10 +29,14 @@ function normalizeResponse( typeName: string, options: NormalizationOptions, useExecTimeResolvers: boolean, + existingRootRecord?: ?Record, ): RelayResponsePayload { const {data, errors} = response; const source = RelayRecordSource.create(); - const record = RelayModernRecord.create(selector.dataID, typeName); + const record = + existingRootRecord != null + ? RelayModernRecord.clone(existingRootRecord) + : RelayModernRecord.create(selector.dataID, typeName); source.set(selector.dataID, record); const relayPayload = RelayResponseNormalizer.normalize( source, diff --git a/packages/relay-runtime/store/resolveDeferSubPathChunk.js b/packages/relay-runtime/store/resolveDeferSubPathChunk.js new file mode 100644 index 0000000000000..6ef81907ed052 --- /dev/null +++ b/packages/relay-runtime/store/resolveDeferSubPathChunk.js @@ -0,0 +1,198 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type {GraphQLResponseWithData} from '../network/RelayNetworkTypes'; +import type { + NormalizationLinkedField, + NormalizationSelectableNode, + NormalizationSelection, +} from '../util/NormalizationNode'; +import type {DataID, Variables} from '../util/RelayRuntimeTypes'; +import type { + DeferPlaceholder, + NormalizationSelector, + Record, + RecordSource, +} from './RelayStoreTypes'; + +import RelayModernRecord from './RelayModernRecord'; +import {createNormalizationSelector} from './RelayModernSelector'; +import {getStorageKey} from './RelayStoreUtils'; + +export type RecoveredDeferPayload = { + readonly selector: NormalizationSelector, + readonly typeName: string, + readonly response: GraphQLResponseWithData, + readonly existingRootRecord: ?Record, +}; + +/** + * Recover the effective selector + response for a deferred chunk that + * landed at a `subPath` beyond its placeholder's registration path. + * Returns null when the addressed location can't be resolved in the store. + */ +function resolveDeferSubPathChunk( + placeholder: DeferPlaceholder, + response: GraphQLResponseWithData, + subPath: ReadonlyArray, + storeSource: RecordSource, +): ?RecoveredDeferPayload { + return subPath.some(key => typeof key === 'number') + ? recoverAtChildRecord(placeholder, response, subPath, storeSource) + : recoverAtParentRecord(placeholder, response, subPath, storeSource); +} + +function recoverAtParentRecord( + placeholder: DeferPlaceholder, + response: GraphQLResponseWithData, + subPath: ReadonlyArray, + storeSource: RecordSource, +): RecoveredDeferPayload { + return { + selector: placeholder.selector, + typeName: placeholder.typeName, + response: {...response, data: wrapUnderSubPath(subPath, response.data)}, + existingRootRecord: storeSource.get(placeholder.selector.dataID), + }; +} + +function recoverAtChildRecord( + placeholder: DeferPlaceholder, + response: GraphQLResponseWithData, + subPath: ReadonlyArray, + storeSource: RecordSource, +): ?RecoveredDeferPayload { + const walk = walkSubPathToLinkedField( + placeholder.selector.node, + subPath, + storeSource, + placeholder.selector.dataID, + placeholder.selector.variables, + ); + if (walk == null) return null; + const {dataID, field} = walk; + const {concreteType} = field; + if (concreteType == null) return null; + return { + selector: createNormalizationSelector( + field, + dataID, + placeholder.selector.variables, + ), + typeName: concreteType, + response, + existingRootRecord: storeSource.get(dataID), + }; +} + +/** + * Fold string subPath keys back into `data`: + * `wrapUnderSubPath(['address'], {city}) → {address: {city}}`. + */ +function wrapUnderSubPath( + subPath: ReadonlyArray, + data: unknown, +): {[string]: unknown} { + let wrapped: {[string]: unknown} = { + [String(subPath[subPath.length - 1])]: data, + }; + for (let i = subPath.length - 2; i >= 0; i--) { + wrapped = {[String(subPath[i])]: wrapped}; + } + return wrapped; +} + +type WalkState = { + readonly field: ?NormalizationLinkedField, + readonly selections: ReadonlyArray, + readonly dataID: DataID, + readonly pendingIDs: ?ReadonlyArray, +}; + +/** + * Walk the fragment's normalization AST alongside the store, following each + * `subPath` key (string = LinkedField name, number = index into the previous + * plural link), to find the target LinkedField and its item dataID. + */ +function walkSubPathToLinkedField( + node: NormalizationSelectableNode, + subPath: ReadonlyArray, + storeSource: RecordSource, + rootDataID: DataID, + variables: Variables, +): ?{dataID: DataID, field: NormalizationLinkedField} { + let state: WalkState = { + field: null, + selections: node.selections, + dataID: rootDataID, + pendingIDs: null, + }; + for (const key of subPath) { + const next = + typeof key === 'string' + ? stepIntoField(state, key, storeSource, variables) + : typeof key === 'number' + ? stepIntoIndex(state, key) + : null; + if (next == null) return null; + state = next; + } + return state.field != null + ? {dataID: state.dataID, field: state.field} + : null; +} + +function stepIntoField( + state: WalkState, + name: string, + storeSource: RecordSource, + variables: Variables, +): ?WalkState { + const field = findLinkedField(state.selections, name); + if (field == null) return null; + const record = storeSource.get(state.dataID); + if (record == null) return null; + const storageKey = getStorageKey(field, variables); + if (field.plural === true) { + const pendingIDs = RelayModernRecord.getLinkedRecordIDs(record, storageKey); + if (pendingIDs == null) return null; + return {field, selections: field.selections, dataID: state.dataID, pendingIDs}; + } + const linked = RelayModernRecord.getLinkedRecordID(record, storageKey); + if (linked == null) return null; + return {field, selections: field.selections, dataID: linked, pendingIDs: null}; +} + +function stepIntoIndex(state: WalkState, index: number): ?WalkState { + if (state.pendingIDs == null) return null; + const itemID = state.pendingIDs[index]; + if (itemID == null) return null; + return {...state, dataID: itemID, pendingIDs: null}; +} + +function findLinkedField( + selections: ReadonlyArray, + responseKey: string, +): ?NormalizationLinkedField { + for (const selection of selections) { + if ( + selection.kind === 'LinkedField' && + (selection.alias ?? selection.name) === responseKey + ) { + return selection; + } + } + return null; +} + +module.exports = resolveDeferSubPathChunk; From e8071038545636e3ab3520d640344e8e739d2ef2 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Wed, 22 Jul 2026 12:52:04 +0100 Subject: [PATCH 2/6] Add two tests locking in the @defer subPath fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js + `processes deferred sub-record payloads addressed via numeric+string subPath` — exercises the mixed walk: parent selects `allPhones { phoneNumber { displayNumber } }` and a `@defer`'d fragment selects `allPhones { phoneNumber { countryCode } }`. graphql-core dedups the shared sub-selections, so per-item chunks arrive with `path: ['node', 'allPhones', , 'phoneNumber']` and just `{countryCode}`. `_walkDeferSubPath` steps into the plural field, then the numeric index, then the non-plural sub-record, and the chunk normalises into that PhoneNumber record. Reader returns the merged data on every item. RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js + `populates the connection edges from the deferred payload` — the fragment-nested @defer shape: a wrapper fragment on User contains `...ConnectionFragment @defer`, and ConnectionFragment selects `friends(first: 2) @connection(...)`. Historically the deferred chunk's `edges` came through empty even though it arrived with two nodes; the connection handler wasn't seeing the edges list on hydration. This test asserts that with the subPath fix in place, the deferred chunk's edges land in the store and the reader returns the assembled connection (edges + cursor + pageInfo). Both tests run against RelayModernEnvironment and MultiActorEnvironment via `describe.each`. 8 total cases, all passing. --- ...ronment-ExecuteWithDeferAndSubPath-test.js | 87 +++++++ ...teWithDeferInFragmentAndConnection-test.js | 195 ++++++++++++++ ...AndSubPathTestSubRecordFragment.graphql.js | 75 ++++++ ...ferAndSubPathTestSubRecordQuery.graphql.js | 213 +++++++++++++++ ...mentAndConnectionTestConnection.graphql.js | 155 +++++++++++ ...nFragmentAndConnectionTestQuery.graphql.js | 246 ++++++++++++++++++ ...ragmentAndConnectionTestWrapper.graphql.js | 70 +++++ 7 files changed, 1041 insertions(+) create mode 100644 packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper.graphql.js diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js index d74402c7a73be..0a68fd2070d82 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js @@ -145,6 +145,93 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( }); }); + it('processes deferred sub-record payloads addressed via numeric+string subPath', () => { + // Parent selects `allPhones { phoneNumber { displayNumber } }`. + // Deferred fragment additionally selects `phoneNumber { countryCode }` + // — the only field not covered by the parent. graphql-core dedups + // the shared sub-selections, so the incremental chunk for each item + // arrives at `path: ['node', 'allPhones', , 'phoneNumber']` with + // just `{countryCode}`. The final `phoneNumber` key is a string + // following the numeric index, which exercises the walk that mixes + // stepIntoIndex (list item) and stepIntoField (sub-record). Reader + // sees the merged scalars on both list items. + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + id + allPhones { + phoneNumber { + displayNumber + } + } + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment + @dangerously_unaliased_fixme + @defer(label: "SubRecordFragment") + } + } + } + `; + const fragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment on User { + allPhones { + phoneNumber { + countryCode + } + } + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const selector = createReaderSelector( + fragment, + '1', + {}, + operation.request, + ); + + environment.execute({operation}).subscribe(callbacks); + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + allPhones: [ + {phoneNumber: {displayNumber: '+1-555-0100'}}, + {phoneNumber: {displayNumber: '+1-555-0101'}}, + ], + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + + dataSource.next({ + data: {countryCode: 'US'}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$defer$SubRecordFragment', + path: ['node', 'allPhones', 0, 'phoneNumber'], + }); + dataSource.next({ + data: {countryCode: 'CA'}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$defer$SubRecordFragment', + path: ['node', 'allPhones', 1, 'phoneNumber'], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + const snapshot = environment.lookup(selector); + expect(snapshot.isMissingData).toBe(false); + expect(snapshot.data).toEqual({ + allPhones: [ + {phoneNumber: {countryCode: 'US'}}, + {phoneNumber: {countryCode: 'CA'}}, + ], + }); + }); + it('processes deferred per-item payloads addressed via numeric subPath', () => { const query = graphql` query RelayModernEnvironmentExecuteWithDeferAndSubPathTestPhonesQuery( diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js new file mode 100644 index 0000000000000..a85bfd1cefbc3 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js @@ -0,0 +1,195 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type {GraphQLResponse} from 'relay-runtime/network/RelayNetworkTypes'; +import type {Sink} from 'relay-runtime/network/RelayObservable'; +import type {RequestParameters} from 'relay-runtime/util/RelayConcreteNode'; +import type { + CacheConfig, + Variables, +} from 'relay-runtime/util/RelayRuntimeTypes'; + +const { + MultiActorEnvironment, + getActorIdentifier, +} = require('relay-runtime/multi-actor-environment'); +const RelayNetwork = require('relay-runtime/network/RelayNetwork'); +const RelayObservable = require('relay-runtime/network/RelayObservable'); +const {graphql} = require('relay-runtime/query/GraphQLTag'); +const RelayModernEnvironment = require('relay-runtime/store/RelayModernEnvironment'); +const { + createOperationDescriptor, +} = require('relay-runtime/store/RelayModernOperationDescriptor'); +const { + createReaderSelector, +} = require('relay-runtime/store/RelayModernSelector'); +const RelayModernStore = require('relay-runtime/store/RelayModernStore'); +const RelayRecordSource = require('relay-runtime/store/RelayRecordSource'); +const {disallowWarnings} = require('relay-test-utils-internal'); + +disallowWarnings(); + +// The historical shape that this test locks in: +// +// fragment WrapperFragment on User { +// ...ConnectionFragment @defer # fragment-nested defer … +// } +// +// fragment ConnectionFragment on User { +// friends(first: 2) @connection(...) { # … on a fragment that uses @connection +// edges { node { id, name } } +// } +// } +// +// The deferred chunk carries `friends.edges` plus the connection scaffolding +// (`__id`, `pageInfo`). After processing, `useFragment` on `ConnectionFragment` +// should return the assembled edges — not an empty `edges: []`. + +describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( + 'execute() a query with @defer on a fragment-nested spread that uses @connection', + environmentType => { + let callbacks; + let complete; + let dataSource; + let environment; + let error; + let next; + + describe(environmentType, () => { + beforeEach(() => { + complete = jest.fn<[], unknown>(); + error = jest.fn<[Error], unknown>(); + next = jest.fn<[GraphQLResponse], unknown>(); + callbacks = {complete, error, next}; + const fetch = ( + _query: RequestParameters, + _variables: Variables, + _cacheConfig: CacheConfig, + ): RelayObservable => { + return RelayObservable.create( + (sink: Sink) => { + dataSource = sink; + }, + ); + }; + const store = new RelayModernStore(RelayRecordSource.create()); + const multiActorEnvironment = new MultiActorEnvironment({ + createNetworkForActor: _actorID => RelayNetwork.create(fetch), + createStoreForActor: _actorID => store, + }); + environment = + environmentType === 'MultiActorEnvironment' + ? multiActorEnvironment.forActor(getActorIdentifier('actor:1234')) + : new RelayModernEnvironment({ + network: RelayNetwork.create(fetch), + store, + }); + }); + + it('populates the connection edges from the deferred payload', () => { + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + ...RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper + @dangerously_unaliased_fixme + } + } + } + `; + const wrapper = graphql` + fragment RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper on User { + id + ...RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection + @dangerously_unaliased_fixme + @defer(label: "ConnectionFragment") + } + `; + const connectionFragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection on User { + friends(first: 2) + @connection( + key: "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTest_friends" + ) { + edges { + node { + id + name + } + } + } + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const selector = createReaderSelector( + connectionFragment, + '1', + {}, + operation.request, + ); + + environment.execute({operation}).subscribe(callbacks); + // Initial payload contains the User node + id, but not the deferred + // connection selection. + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + + // Deferred chunk carrying the connection data. Path here targets the + // node's User fragment; the label matches the compiled Defer node. + dataSource.next({ + data: { + friends: { + edges: [ + {node: {id: 'u2', name: 'Alice', __typename: 'User'}}, + {node: {id: 'u3', name: 'Bob', __typename: 'User'}}, + ], + pageInfo: { + endCursor: 'cursor-2', + hasNextPage: false, + }, + }, + }, + label: + 'RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$defer$ConnectionFragment', + path: ['node'], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + const snapshot = environment.lookup(selector); + expect(snapshot.isMissingData).toBe(false); + // Historical bug (pre-patch): `edges` came through empty even though + // the deferred chunk arrived with two nodes. This asserts the fix: + // edges are populated in the store and read back correctly. + expect(snapshot.data).toEqual({ + friends: { + edges: [ + {node: {id: 'u2', name: 'Alice'}}, + {node: {id: 'u3', name: 'Bob'}}, + ], + }, + }); + }); + }); + }, +); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment.graphql.js new file mode 100644 index 0000000000000..79537048e408b --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment.graphql.js @@ -0,0 +1,75 @@ +/** + * @generated SignedSource<<0000000000000000000000000000000000>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$data = { + readonly allPhones: ?ReadonlyArray, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "countryCode", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "User", + "abstractKey": null +}; + +(node/*:: as any*/).hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"; + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery.graphql.js new file mode 100644 index 0000000000000..2309bf0481b71 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery.graphql.js @@ -0,0 +1,213 @@ +/** + * @generated SignedSource<<0000000000000000000000000000000000>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment.graphql"; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$variables = { + id: string, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$data = { + readonly node: ?{ + readonly allPhones?: ?ReadonlyArray, + readonly id?: string, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment$fragmentType, + }, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery = { + response: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$data, + variables: RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$variables, +}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "displayNumber", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*:: as any*/), + (v3/*:: as any*/), + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment" + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Operation", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*:: as any*/), + { + "kind": "InlineFragment", + "selections": [ + (v3/*:: as any*/), + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$defer$SubRecordFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "countryCode", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2", + "id": null, + "metadata": {}, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery", + "operationKind": "query", + "text": "query RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on User {\n id\n allPhones {\n phoneNumber {\n displayNumber\n }\n }\n ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment @defer(label: \"RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$defer$SubRecordFragment\")\n }\n id\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordFragment on User {\n allPhones {\n phoneNumber {\n countryCode\n }\n }\n}\n" + } +}; +})(); + +(node/*:: as any*/).hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3"; + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$variables, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection.graphql.js new file mode 100644 index 0000000000000..f79999783b1fb --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection.graphql.js @@ -0,0 +1,155 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<<3b7025c3dc336899e47460ee187764a9>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$data = { + readonly friends: ?{ + readonly edges: ?ReadonlyArray, + }, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "forward", + "path": [ + "friends" + ] + } + ] + }, + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection", + "selections": [ + { + "alias": "friends", + "args": null, + "concreteType": "FriendsConnection", + "kind": "LinkedField", + "name": "__RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTest_friends_connection", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "FriendsEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "User", + "abstractKey": null +}; + +if (__DEV__) { + (node/*:: as any*/).hash = "5e1107113c0893c5714dc8e2846e6552"; +} + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType, + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery.graphql.js new file mode 100644 index 0000000000000..511a8e50800fc --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery.graphql.js @@ -0,0 +1,246 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper.graphql"; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$variables = { + id: string, +}; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$data = { + readonly node: ?({ + readonly __typename: "User", + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType, + } | { + // This will never be '%other', but we need some + // value in case none of the concrete values match. + readonly __typename: "%other", + }), +}; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery = { + response: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$data, + variables: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$variables, +}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v4 = [ + { + "kind": "Literal", + "name": "first", + "value": 2 + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper" + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Operation", + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*:: as any*/), + (v3/*:: as any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$defer$ConnectionFragment", + "selections": [ + { + "alias": null, + "args": (v4/*:: as any*/), + "concreteType": "FriendsConnection", + "kind": "LinkedField", + "name": "friends", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "FriendsEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v3/*:: as any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + (v2/*:: as any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "friends(first:2)" + }, + { + "alias": null, + "args": (v4/*:: as any*/), + "filters": null, + "handle": "connection", + "key": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTest_friends", + "kind": "LinkedHandle", + "name": "friends" + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "ad2ef0b08d86553295e138dccf1d9a7a", + "id": null, + "metadata": {}, + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery", + "operationKind": "query", + "text": "query RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on User {\n ...RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper\n }\n id\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection on User {\n friends(first: 2) {\n edges {\n node {\n id\n name\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper on User {\n id\n ...RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection @defer(label: \"RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$defer$ConnectionFragment\")\n}\n" + } +}; +})(); + +if (__DEV__) { + (node/*:: as any*/).hash = "628a7940fe391ca22acbf0aab3a8258a"; +} + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$variables, + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestQuery$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper.graphql.js new file mode 100644 index 0000000000000..e8ad145d08a23 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper.graphql.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection.graphql"; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$data = { + readonly id: string, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection$fragmentType, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestConnection" + } + ] + } + ], + "type": "User", + "abstractKey": null +}; + +if (__DEV__) { + (node/*:: as any*/).hash = "5ace9d3bbeea4c876fb8ba62f8ea1933"; +} + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$fragmentType, + RelayModernEnvironmentExecuteWithDeferInFragmentAndConnectionTestWrapper$data, +>*/); From b804a8a6374f675f35109ff1765cf1ee9bec4db3 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Wed, 22 Jul 2026 13:00:35 +0100 Subject: [PATCH 3/6] Strip explanatory comments from the new @defer tests --- ...ronment-ExecuteWithDeferAndSubPath-test.js | 9 ---- ...teWithDeferInFragmentAndConnection-test.js | 49 +++++++++---------- 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js index 0a68fd2070d82..4e590440c753b 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js @@ -146,15 +146,6 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( }); it('processes deferred sub-record payloads addressed via numeric+string subPath', () => { - // Parent selects `allPhones { phoneNumber { displayNumber } }`. - // Deferred fragment additionally selects `phoneNumber { countryCode }` - // — the only field not covered by the parent. graphql-core dedups - // the shared sub-selections, so the incremental chunk for each item - // arrives at `path: ['node', 'allPhones', , 'phoneNumber']` with - // just `{countryCode}`. The final `phoneNumber` key is a string - // following the numeric index, which exercises the walk that mixes - // stepIntoIndex (list item) and stepIntoField (sub-record). Reader - // sees the merged scalars on both list items. const query = graphql` query RelayModernEnvironmentExecuteWithDeferAndSubPathTestSubRecordQuery( $id: ID! diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js index a85bfd1cefbc3..8116546613be2 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferInFragmentAndConnection-test.js @@ -39,22 +39,6 @@ const {disallowWarnings} = require('relay-test-utils-internal'); disallowWarnings(); -// The historical shape that this test locks in: -// -// fragment WrapperFragment on User { -// ...ConnectionFragment @defer # fragment-nested defer … -// } -// -// fragment ConnectionFragment on User { -// friends(first: 2) @connection(...) { # … on a fragment that uses @connection -// edges { node { id, name } } -// } -// } -// -// The deferred chunk carries `friends.edges` plus the connection scaffolding -// (`__id`, `pageInfo`). After processing, `useFragment` on `ConnectionFragment` -// should return the assembled edges — not an empty `edges: []`. - describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( 'execute() a query with @defer on a fragment-nested spread that uses @connection', environmentType => { @@ -141,8 +125,6 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( ); environment.execute({operation}).subscribe(callbacks); - // Initial payload contains the User node + id, but not the deferred - // connection selection. dataSource.next({ data: { node: { @@ -154,18 +136,24 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( jest.runAllTimers(); next.mockClear(); - // Deferred chunk carrying the connection data. Path here targets the - // node's User fragment; the label matches the compiled Defer node. dataSource.next({ data: { friends: { edges: [ - {node: {id: 'u2', name: 'Alice', __typename: 'User'}}, - {node: {id: 'u3', name: 'Bob', __typename: 'User'}}, + { + cursor: 'cursor-1', + node: {id: 'u2', name: 'Alice', __typename: 'User'}, + }, + { + cursor: 'cursor-2', + node: {id: 'u3', name: 'Bob', __typename: 'User'}, + }, ], pageInfo: { endCursor: 'cursor-2', hasNextPage: false, + startCursor: 'cursor-1', + hasPreviousPage: false, }, }, }, @@ -178,15 +166,22 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( expect(error).toBeCalledTimes(0); const snapshot = environment.lookup(selector); expect(snapshot.isMissingData).toBe(false); - // Historical bug (pre-patch): `edges` came through empty even though - // the deferred chunk arrived with two nodes. This asserts the fix: - // edges are populated in the store and read back correctly. expect(snapshot.data).toEqual({ friends: { edges: [ - {node: {id: 'u2', name: 'Alice'}}, - {node: {id: 'u3', name: 'Bob'}}, + { + cursor: 'cursor-1', + node: {__typename: 'User', id: 'u2', name: 'Alice'}, + }, + { + cursor: 'cursor-2', + node: {__typename: 'User', id: 'u3', name: 'Bob'}, + }, ], + pageInfo: { + endCursor: 'cursor-2', + hasNextPage: false, + }, }, }); }); From 6f0256ab3a48e2c3a2f7df348118e28fecb92405 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Fri, 24 Jul 2026 13:16:03 +0100 Subject: [PATCH 4/6] Register nested @defer placeholders eagerly during subPath chunk processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a chunk arrives with a `subPath` and lands via prefix-match on a parent @defer's placeholder, `_processDeferResponse` normalizes only the addressed sub-selection (a single sub-record, or a per-item slice of a non-Node list). The parent fragment's top-level selections are never walked, so any nested `Defer` AST node inside that fragment is never encountered by the normalizer — its label never enters `_incrementalResults`. Chunks for the nested defer then arrive with an unregistered label, get buffered as pending responses, and are silently dropped. Reproduces on any query shape where an outer @defer'd fragment selects a non-Node list AND declares an inner @defer, e.g.: query Q($id: ID!) { node(id: $id) { ... on User { allPhones { phoneNumber { displayNumber } } ...Outer @defer(label: "Outer") } } } fragment Outer on User { allPhones { isVerified } ...Inner @defer(label: "Inner") } The `Outer` chunk streams as per-item sub-path chunks (Bug B path) and never as a root chunk; the `Inner` chunk arrives at `['node']` with `label: 'Q$defer$OuterFragment$defer$Inner'` and no placeholder exists for it. Fix: after successfully looking up (either exact-match or via prefix-match) a parent @defer's placeholder in `_processIncrementalResponses`, walk the parent's fragment selections for `Defer` AST nodes and register their placeholders eagerly, mirroring what `_normalizeDefer` would do if the fragment root were normalized. Idempotent (skips labels already registered), recurses so multi-level nesting is handled, and drains any queued response chunks that arrived before the placeholder existed. Extracted helpers, module-level: * `forEachInnerDefer(selector, visit)` — walks the top-level selections of the placeholder's fragment for `Defer` nodes, descending through wrapper selections that don't change the record scope (`InlineFragment`, `ClientExtension`, `Condition`). Conditional defers are honored: `@defer(if: $var)` is skipped when the variable resolves falsy at the placeholder's variables, matching `_normalizeDefer`. * `buildInnerDeferPlaceholder(parent, defer)` — constructs a `DeferPlaceholder` from a parent placeholder + a child `Defer` AST node. RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js + `processes a nested @defer chunk when the outer defer streams as sub-path chunks only` — outer @defer fragment streams only per-item chunks (['node', 'allPhones', 0]), then an inner @defer's chunk arrives at ['node']. Asserts the reader returns the inner defer's data. Runs against both `RelayModernEnvironment` and `MultiActorEnvironment` via `describe.each`. Verified to fail without the fix. Fixes: nested @defer under a subPath-streamed parent (companion to #5354). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../relay-runtime/store/OperationExecutor.js | 98 ++++++++ ...ronment-ExecuteWithDeferAndSubPath-test.js | 84 +++++++ ...dSubPathTestNestedInnerFragment.graphql.js | 58 +++++ ...dSubPathTestNestedOuterFragment.graphql.js | 83 +++++++ ...hDeferAndSubPathTestNestedQuery.graphql.js | 230 ++++++++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment.graphql.js create mode 100644 packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery.graphql.js diff --git a/packages/relay-runtime/store/OperationExecutor.js b/packages/relay-runtime/store/OperationExecutor.js index b72d24b162c68..9b9f1f2f315ba 100644 --- a/packages/relay-runtime/store/OperationExecutor.js +++ b/packages/relay-runtime/store/OperationExecutor.js @@ -42,12 +42,15 @@ import type { TaskScheduler, } from '../store/RelayStoreTypes'; import type { + NormalizationDefer, NormalizationLinkedField, NormalizationOperation, NormalizationRootNode, NormalizationSelectableNode, + NormalizationSelection, NormalizationSplitOperation, } from '../util/NormalizationNode'; +import type {NormalizationSelector} from './RelayStoreTypes'; import type {DataID, Disposable, Variables} from '../util/RelayRuntimeTypes'; import type {GetDataID} from './RelayResponseNormalizer'; import type {NormalizeResponseFunction} from './RelayStoreTypes'; @@ -1387,6 +1390,7 @@ class Executor { label, placeholder.kind, ); + this._registerInnerDeferPlaceholders(placeholder); relayPayloads.push( this._processDeferResponse( label, @@ -1428,6 +1432,45 @@ class Executor { return relayPayloads; } + /** + * Register placeholders for any top-level Defer selections inside a parent + * defer's fragment. Needed when the parent's data streams as sub-path + * chunks only (per-item, single sub-record) — the fragment root is never + * normalized, so inner @defer AST nodes are never encountered and their + * chunks would arrive with an unregistered label and be silently dropped. + * Idempotent, recurses so multiple levels of nesting are handled, and + * drains any responses that queued before the placeholder existed. + */ + _registerInnerDeferPlaceholders(parentPlaceholder: DeferPlaceholder): void { + const parentPathKey = parentPlaceholder.path.map(String).join('.'); + forEachInnerDefer(parentPlaceholder.selector, defer => { + let resultForLabel = this._incrementalResults.get(defer.label); + if (resultForLabel == null) { + resultForLabel = new Map(); + this._incrementalResults.set(defer.label, resultForLabel); + } + const existing = resultForLabel.get(parentPathKey); + if (existing != null && existing.kind === 'placeholder') { + return; + } + const innerPlaceholder = buildInnerDeferPlaceholder( + parentPlaceholder, + defer, + ); + resultForLabel.set(parentPathKey, { + kind: 'placeholder', + placeholder: innerPlaceholder, + }); + this._registerInnerDeferPlaceholders(innerPlaceholder); + if (existing != null && existing.kind === 'response') { + const payloadFollowups = this._processIncrementalResponses( + existing.responses, + ); + this._processPayloadFollowups(payloadFollowups); + } + }); + } + _processDeferResponse( label: string, path: ReadonlyArray, @@ -1879,6 +1922,61 @@ function findDeferPlaceholderByPrefix( return null; } +/** + * Walk the top-level selections of a placeholder's fragment for Defer nodes, + * descending through wrapper selections that don't change the record scope + * (InlineFragment, ClientExtension, Condition). Conditional defers are + * skipped when their `if` variable resolves to false at the placeholder's + * variables — matching `_normalizeDefer`'s behavior. + */ +function forEachInnerDefer( + selector: NormalizationSelector, + visit: (defer: NormalizationDefer) => void, +): void { + const walk = (selections: ReadonlyArray) => { + for (const sel of selections) { + switch (sel.kind) { + case 'Defer': + if ( + sel.if === null || + Boolean(selector.variables[sel.if]) + ) { + visit(sel); + } + break; + case 'InlineFragment': + case 'ClientExtension': + case 'Condition': + walk(sel.selections); + break; + } + } + }; + const selections = selector.node.selections; + if (selections != null) { + walk(selections); + } +} + +function buildInnerDeferPlaceholder( + parent: DeferPlaceholder, + defer: NormalizationDefer, +): DeferPlaceholder { + return { + actorIdentifier: parent.actorIdentifier, + data: {}, + kind: 'defer', + label: defer.label, + path: parent.path, + selector: { + dataID: parent.selector.dataID, + node: defer, + variables: parent.selector.variables, + }, + typeName: parent.typeName, + }; +} + module.exports = { execute, }; diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js index 4e590440c753b..dc157714fc9ac 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js @@ -307,6 +307,90 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( ], }); }); + + it('processes a nested @defer chunk when the outer defer streams as sub-path chunks only', () => { + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + id + allPhones { + phoneNumber { + displayNumber + } + } + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment + @dangerously_unaliased_fixme + @defer(label: "NestedOuterFragment") + } + } + } + `; + graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment on User { + allPhones { + isVerified + } + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment + @defer(label: "NestedInnerFragment") + } + `; + const innerFragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment on User { + name + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const innerSelector = createReaderSelector( + innerFragment, + '1', + {}, + operation.request, + ); + + environment.execute({operation}).subscribe(callbacks); + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + allPhones: [ + {phoneNumber: {displayNumber: '+1-555-0100'}}, + ], + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + + // Outer @defer's data streams ONLY as sub-path chunks (per-item). + // The outer fragment root at ['node'] never gets a chunk of its own, + // so a direct normalisation of the outer fragment never happens. + dataSource.next({ + data: {isVerified: true}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$defer$NestedOuterFragment', + path: ['node', 'allPhones', 0], + }); + + // Inner @defer chunk arrives at the outer's parent path, addressed + // by the inner label. Without the eager inner-placeholder registration, + // no placeholder exists for this label and the chunk is silently dropped. + dataSource.next({ + data: {name: 'Alice'}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$defer$NestedInnerFragment', + path: ['node'], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + const snapshot = environment.lookup(innerSelector); + expect(snapshot.isMissingData).toBe(false); + expect(snapshot.data).toEqual({name: 'Alice'}); + }); }); }, ); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment.graphql.js new file mode 100644 index 0000000000000..dfadd3f7ac8c2 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment.graphql.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$data = { + readonly name: ?string, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "type": "User", + "abstractKey": null +}; + +if (__DEV__) { + (node/*:: as any*/).hash = "59c0141e275b2d5eec1b385755517121"; +} + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment.graphql.js new file mode 100644 index 0000000000000..888ff89d6fb25 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment.graphql.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { Fragment, ReaderFragment } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment.graphql"; +import type { FragmentType } from "relay-runtime"; +declare export opaque type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType: FragmentType; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$data = { + readonly allPhones: ?ReadonlyArray, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment$fragmentType, + readonly $fragmentType: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$key = { + readonly $data?: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$data, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType, + ... +}; +*/ + +var node/*: ReaderFragment*/ = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "isVerified", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment" + } + ] + } + ], + "type": "User", + "abstractKey": null +}; + +if (__DEV__) { + (node/*:: as any*/).hash = "5f2372e3381446e313db1309beaf91a7"; +} + +module.exports = ((node/*:: as any*/)/*:: as Fragment< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery.graphql.js new file mode 100644 index 0000000000000..5fd1a9f628408 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery.graphql.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<<6f6535ffbc9b7458ef23684766e33ea6>> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType } from "./RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment.graphql"; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$variables = { + id: string, +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$data = { + readonly node: ?({ + readonly __typename: "User", + readonly allPhones: ?ReadonlyArray, + readonly id: string, + readonly $fragmentSpreads: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$fragmentType, + } | { + // This will never be '%other', but we need some + // value in case none of the concrete values match. + readonly __typename: "%other", + }), +}; +export type RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery = { + response: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$data, + variables: RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$variables, +}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PhoneNumber", + "kind": "LinkedField", + "name": "phoneNumber", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "displayNumber", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Fragment", + "metadata": null, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*:: as any*/), + (v3/*:: as any*/), + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment" + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*:: as any*/), + "kind": "Operation", + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery", + "selections": [ + { + "alias": null, + "args": (v1/*:: as any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*:: as any*/), + { + "kind": "InlineFragment", + "selections": [ + (v3/*:: as any*/), + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$defer$NestedOuterFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Phone", + "kind": "LinkedField", + "name": "allPhones", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "isVerified", + "storageKey": null + } + ], + "storageKey": null + }, + { + "if": null, + "kind": "Defer", + "label": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$defer$NestedInnerFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ] + } + ] + } + ], + "type": "User", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "450865a9ea11afe26c8f79351f5552cb", + "id": null, + "metadata": {}, + "name": "RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery", + "operationKind": "query", + "text": "query RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ... on User {\n id\n allPhones {\n phoneNumber {\n displayNumber\n }\n }\n ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment @defer(label: \"RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$defer$NestedOuterFragment\")\n }\n id\n }\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment on User {\n name\n}\n\nfragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment on User {\n allPhones {\n isVerified\n }\n ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment @defer(label: \"RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$defer$NestedInnerFragment\")\n}\n" + } +}; +})(); + +if (__DEV__) { + (node/*:: as any*/).hash = "dfc01954ee757284b4310b2d0208711c"; +} + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$variables, + RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery$data, +>*/); From 1c233b618faf2323a14f1c4556a4e9f704181a28 Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Fri, 24 Jul 2026 16:12:35 +0100 Subject: [PATCH 5/6] Also register inner @defer placeholders when parent placeholder is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (6f0256ab3a48) registered inner defer placeholders when a chunk arrived matching the parent placeholder. That covers the case where the parent streams as sub-path chunks (per-item, single sub-record) so the parent root is never normalized. It misses another case: when the parent @defer selects only inner @defer fragments (no scalar/list fields of its own), the server never emits a chunk for the parent label at all — only chunks for its inner defers. `_registerInnerDeferPlaceholders` never fires, so the inner chunks arrive with no registered placeholder and get queued forever in `_incrementalResults`. Fix: also call `_registerInnerDeferPlaceholders(placeholder)` from `_processIncrementalPlaceholder` when a defer placeholder is created from the initial payload's `incrementalPlaceholders`. Idempotent registration keeps the existing chunk-arrival call safe. --- .../relay-runtime/store/OperationExecutor.js | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/relay-runtime/store/OperationExecutor.js b/packages/relay-runtime/store/OperationExecutor.js index 9b9f1f2f315ba..d0c507520c0ee 100644 --- a/packages/relay-runtime/store/OperationExecutor.js +++ b/packages/relay-runtime/store/OperationExecutor.js @@ -1270,6 +1270,13 @@ class Executor { : null; resultForLabel.set(pathKey, {kind: 'placeholder', placeholder}); + // If the parent has no data of its own (only inner @defers), the + // server never emits a parent chunk — register inner placeholders + // here so their chunks find a home. + if (placeholder.kind === 'defer') { + this._registerInnerDeferPlaceholders(placeholder); + } + // Store references to the parent node to allow detecting concurrent // modifications to the parent before items arrive and to replay // handle field payloads to account for new information on source records. @@ -1434,12 +1441,19 @@ class Executor { /** * Register placeholders for any top-level Defer selections inside a parent - * defer's fragment. Needed when the parent's data streams as sub-path - * chunks only (per-item, single sub-record) — the fragment root is never - * normalized, so inner @defer AST nodes are never encountered and their - * chunks would arrive with an unregistered label and be silently dropped. - * Idempotent, recurses so multiple levels of nesting are handled, and - * drains any responses that queued before the placeholder existed. + * defer's fragment. Called from two sites so both failure modes are covered: + * - `_processIncrementalPlaceholder`, when the parent placeholder is + * created from the initial payload — needed if the parent has no data + * of its own (only inner @defers), because in that case the server + * never emits a chunk for the parent label. + * - `_processIncrementalResponses`, when a chunk arrives that matches + * the parent placeholder — needed when the parent streams as sub-path + * chunks only (per-item, single sub-record), so the fragment root is + * never normalized and inner @defer AST nodes are never encountered. + * Without either registration, inner chunks arrive with an unregistered + * label and get silently queued forever. Idempotent, recurses so multiple + * levels of nesting are handled, and drains any responses that queued + * before the placeholder existed. */ _registerInnerDeferPlaceholders(parentPlaceholder: DeferPlaceholder): void { const parentPathKey = parentPlaceholder.path.map(String).join('.'); From 7528fb6022323b2a8149e5d2941e2ee11c32a09d Mon Sep 17 00:00:00 2001 From: Jon Reading Date: Fri, 24 Jul 2026 16:44:04 +0100 Subject: [PATCH 6/6] Add test locking in the empty-outer-defer case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to the existing 'outer defer streams as sub-path chunks only' test — same fragment topology, but the server emits NO chunk at all for the outer label. Mirrors the production shape where the outer fragment's non-defer selections resolve to null/empty and the server skips its chunk. Verified fails on parent commit (invariant thrown when inner chunk finds no placeholder), passes with the fix in place. --- ...ronment-ExecuteWithDeferAndSubPath-test.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js index dc157714fc9ac..05f5f5106efde 100644 --- a/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js +++ b/packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteWithDeferAndSubPath-test.js @@ -391,6 +391,74 @@ describe.each(['RelayModernEnvironment', 'MultiActorEnvironment'])( expect(snapshot.isMissingData).toBe(false); expect(snapshot.data).toEqual({name: 'Alice'}); }); + + it('processes a nested @defer chunk when the outer defer emits no chunk of its own', () => { + const query = graphql` + query RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedQuery( + $id: ID! + ) { + node(id: $id) { + ... on User { + id + allPhones { + phoneNumber { + displayNumber + } + } + ...RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment + @dangerously_unaliased_fixme + @defer(label: "NestedOuterFragment") + } + } + } + `; + const innerFragment = graphql` + fragment RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedInnerFragment on User { + name + } + `; + const operation = createOperationDescriptor(query, {id: '1'}); + const innerSelector = createReaderSelector( + innerFragment, + '1', + {}, + operation.request, + ); + + environment.execute({operation}).subscribe(callbacks); + dataSource.next({ + data: { + node: { + id: '1', + __typename: 'User', + allPhones: [ + {phoneNumber: {displayNumber: '+1-555-0100'}}, + ], + }, + }, + }); + jest.runAllTimers(); + next.mockClear(); + + // No outer chunk arrives — mirrors the server-side optimisation of + // skipping the parent label when its non-defer selections resolve + // to nothing. Inner @defer chunk arrives directly at the parent + // path. Without the eager placeholder registration triggered when + // the outer placeholder itself is created, no inner placeholder + // exists and the chunk is silently dropped. + dataSource.next({ + data: {name: 'Alice'}, + label: + 'RelayModernEnvironmentExecuteWithDeferAndSubPathTestNestedOuterFragment$defer$NestedInnerFragment', + path: ['node'], + }); + + expect(complete).toBeCalledTimes(0); + expect(error).toBeCalledTimes(0); + const snapshot = environment.lookup(innerSelector); + expect(snapshot.isMissingData).toBe(false); + expect(snapshot.data).toEqual({name: 'Alice'}); + }); }); }, );