Summary
RelayResponseNormalizer._normalizeTypeDiscriminator and RelayResponseNormalizer._normalizeInlineFragment compute implementsInterface = data.hasOwnProperty(abstractKey) and unconditionally write that value to client:__type:<ConcreteType>.__is<Interface>.
Under Environment({ deferDeduplicatedFields: true }), this misinterprets a legitimately-omitted abstract typename in an incremental @defer payload as "type does not implement". The false write corrupts the type registry; DataChecker later reads it and takes the "type does NOT implement" branch of its InlineFragment handler — silently skipping the whole ... on Interface { ... } selection subtree without marking anything missing. Downstream useLazyLoadQuery / usePreloadedQuery believe the store already has all the data, never call the network, and fragment reads collapse under $isWithinUnmatchedTypeRefinement — the reader returns null for every field inside.
Repro fingerprint
- Environment configured with
deferDeduplicatedFields: true.
- Any query using
@defer where the initial chunk visits a concrete-type record whose response doesn't carry an abstract typename that the compiled operation happens to select for that record type elsewhere (via a ... on Node { __isNode: __typename, ... } or TypeDiscriminator selection).
- A later query using
... on <Interface> on the same concrete type returns null for its selections because DataChecker's check returns available without fetching. Symptom in our app: a stats/details page that renders correctly on refresh (SSR does all fetches in one pass) but shows "No data" when client-navigated to (SSR's @defer chunks corrupted the type registry before the client-nav happened).
I confirmed the mechanism by instrumenting store.publish and dumping the type-registry state — __type:Cloudcast.__isNode: false was present after the initial page load. Manually resetting the field to true immediately unblocked all downstream queries.
Location
Two writes in packages/relay-runtime/store/RelayResponseNormalizer.js:
_normalizeTypeDiscriminator (case 'TypeDiscriminator' in _traverseSelections)
_normalizeInlineFragment (the abstractKey != null branch)
Both compute implementsInterface = Object.prototype.hasOwnProperty.call(data, abstractKey) and then RelayModernRecord.setValue(typeRecord, abstractKey, implementsInterface).
DataChecker's read at packages/relay-runtime/store/DataChecker.js (case 'InlineFragment' with abstractKey):
var _implementsInterface = _this2._mutator.getValue(_typeID, _abstractKey);
if (_implementsInterface === true) {
_this2._traverseSelections(selection.selections, dataID);
} else if (_implementsInterface == null) {
_this2._handleMissing();
}
// else — false — silently SKIP, no missing signal
This is the semantics that turns the bad write into an unfetched query.
Why the false write isn't safe under deferDeduplicatedFields
Without defer/dedup, a missing abstract typename in a normalized payload is a real "the concrete type does not implement this interface" signal — the compiler always emits __isFoo: __typename when a fragment refines to Foo, so a truly non-implementing type's response has no __isFoo. Writing false in that case is correct and load-bearing.
With deferDeduplicatedFields, that assumption breaks: the server intentionally omits fields it has already delivered in a previous chunk (or in an operation earlier in the same request). So a missing __isFoo no longer implies non-implementation — it can just mean "delivered earlier". Writing false on that basis corrupts the registry with a claim that contradicts the schema, and DataChecker (which treats false as authoritative-negative) never fetches to correct it.
Proposed fix
Only skip the write when both conditions hold: implementsInterface === false and the normalizer was configured with deferDeduplicatedFields. That preserves existing behaviour for every non-dedup environment (write false on missing tag, still load-bearing), and only affects environments that opt into dedup — where the write is unsound anyway.
// Both TypeDiscriminator and InlineFragment paths:
var implementsInterface = Object.prototype.hasOwnProperty.call(data, abstractKey);
if (implementsInterface || !this._deferDeduplicatedFields) {
// ... existing write logic
RelayModernRecord.setValue(typeRecord, abstractKey, implementsInterface);
}
When the write is skipped, DataChecker treats the record as unknown (_implementsInterface == null) → _handleMissing() → fetch on next check → response arrives with the tag → true written normally. Which is what should happen.
Happy to open the PR — filing this issue first so there's context to link.
Version
Reproduced against relay-runtime@21.0.1. Also present in current main per source inspection.
Summary
RelayResponseNormalizer._normalizeTypeDiscriminatorandRelayResponseNormalizer._normalizeInlineFragmentcomputeimplementsInterface = data.hasOwnProperty(abstractKey)and unconditionally write that value toclient:__type:<ConcreteType>.__is<Interface>.Under
Environment({ deferDeduplicatedFields: true }), this misinterprets a legitimately-omitted abstract typename in an incremental@deferpayload as "type does not implement". Thefalsewrite corrupts the type registry;DataCheckerlater reads it and takes the "type does NOT implement" branch of itsInlineFragmenthandler — silently skipping the whole... on Interface { ... }selection subtree without marking anything missing. DownstreamuseLazyLoadQuery/usePreloadedQuerybelieve the store already has all the data, never call the network, and fragment reads collapse under$isWithinUnmatchedTypeRefinement— the reader returnsnullfor every field inside.Repro fingerprint
deferDeduplicatedFields: true.@deferwhere the initial chunk visits a concrete-type record whose response doesn't carry an abstract typename that the compiled operation happens to select for that record type elsewhere (via a... on Node { __isNode: __typename, ... }orTypeDiscriminatorselection).... on <Interface>on the same concrete type returnsnullfor its selections because DataChecker's check returnsavailablewithout fetching. Symptom in our app: a stats/details page that renders correctly on refresh (SSR does all fetches in one pass) but shows "No data" when client-navigated to (SSR's@deferchunks corrupted the type registry before the client-nav happened).I confirmed the mechanism by instrumenting
store.publishand dumping the type-registry state —__type:Cloudcast.__isNode: falsewas present after the initial page load. Manually resetting the field totrueimmediately unblocked all downstream queries.Location
Two writes in
packages/relay-runtime/store/RelayResponseNormalizer.js:_normalizeTypeDiscriminator(case'TypeDiscriminator'in_traverseSelections)_normalizeInlineFragment(theabstractKey != nullbranch)Both compute
implementsInterface = Object.prototype.hasOwnProperty.call(data, abstractKey)and thenRelayModernRecord.setValue(typeRecord, abstractKey, implementsInterface).DataChecker's read at
packages/relay-runtime/store/DataChecker.js(case'InlineFragment'withabstractKey):This is the semantics that turns the bad write into an unfetched query.
Why the
falsewrite isn't safe underdeferDeduplicatedFieldsWithout defer/dedup, a missing abstract typename in a normalized payload is a real "the concrete type does not implement this interface" signal — the compiler always emits
__isFoo: __typenamewhen a fragment refines toFoo, so a truly non-implementing type's response has no__isFoo. Writingfalsein that case is correct and load-bearing.With
deferDeduplicatedFields, that assumption breaks: the server intentionally omits fields it has already delivered in a previous chunk (or in an operation earlier in the same request). So a missing__isFoono longer implies non-implementation — it can just mean "delivered earlier". Writingfalseon that basis corrupts the registry with a claim that contradicts the schema, and DataChecker (which treatsfalseas authoritative-negative) never fetches to correct it.Proposed fix
Only skip the write when both conditions hold:
implementsInterface === falseand the normalizer was configured withdeferDeduplicatedFields. That preserves existing behaviour for every non-dedup environment (writefalseon missing tag, still load-bearing), and only affects environments that opt into dedup — where the write is unsound anyway.When the write is skipped, DataChecker treats the record as unknown (
_implementsInterface == null) →_handleMissing()→ fetch on next check → response arrives with the tag →truewritten normally. Which is what should happen.Happy to open the PR — filing this issue first so there's context to link.
Version
Reproduced against
relay-runtime@21.0.1. Also present in currentmainper source inspection.