Land @defer chunks that arrive with a subPath (fixes #5354) - #5370
Land @defer chunks that arrive with a subPath (fixes #5354)#5370jonreading81 wants to merge 6 commits into
Conversation
|
Hi @jonreading81! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
c255d2e to
0082694
Compare
dbea995 to
c6c9b4a
Compare
`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: facebook#5354
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', <i>, '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.
…essing 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 facebook#5354). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eated The previous fix (6f0256a) 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.
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.
Summary
Fixes #5371 (supersedes closed #5354).
OperationExecutor._processIncrementalResponseslooks up placeholders by exact(label, chunk.path.join('.')). Placeholders register atpendingPart.path— the fragment-spread path — with nosubPathsuffix. Any chunk arriving from a spec-compliant incremental-delivery server that carries asubPath(e.g. graphql-core 3.3+'s per-item non-Node list chunks, or field-dedup chunks addressed at a sub-record) therefore lands in a map bucket no placeholder ever registers at → buffered forever → silently dropped by the client store.The bug manifests in real Relay clients paired with graphql-core / Strawberry backends. Two documented symptom classes:
@defer'd fragment and a non-deferred sibling both dig through a non-Node list. graphql-core emits per-item chunks withsubPath: [listField, N](or deeper). All chunks are dropped; the list renders empty despite the count / other scalars being correct.@connectionin a fragment-nested spread — same mechanism, different shape. Edge chunks arrive withsubPath: [connField, 'edges', N, 'node', ...]and are dropped; header renders "N items", list renders blank.Fix
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 deltasubPath = path.slice(prefix.length)and hand it to_processDeferResponse._processDeferResponse(newsubPathparam):['owner']): folddataunder the subPath keys and normalise at the placeholder's root selector. Seed the local normalisation source with a clone of the existing store record (seenormalizeResponsechange) 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.walkDeferSubPathhelper walks the fragment's normalization AST alongside the store fromparentID, 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 viasetLinkedRecordIDs.normalizeResponsegrows an optionalexistingRootRecordparameter (also added toNormalizeResponseFunctioninRelayStoreTypes). 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.Test plan
yarn typecheck— clean.yarn test RelayModernEnvironment-ExecuteWithDefer— 78/78 pre-existing defer + deferredStreamedConnection tests pass. Chunks arriving without asubPathstill take the exact-key path, unchanged.subPathshape:@deferon a fragment whose deduplicated data targets a nested Node record (subPath: ['owner'], chunk{isBranded: false}) — the field lands on the existing owner record and the parent's link is preserved.subPathshape:@deferon a fragment overlapping a non-Node list (per-item chunks withsubPath: ['tags', N]) — every item's data lands in its own child record.@connectionshape:@deferon a fragment-nested spread that reaches a@connection(spotlight.users(first: 4)) — all 4 edges land with correct usernames + follower counts.Notes
_processDeferResponsewas intentionally kept in the executor rather than pushed into the normalizer, so the AST + store walk sits alongside the placeholder metadata it already has.Fixes #5371. (Supersedes closed #5354 — same root cause, unified writeup.)