#5389·relay

RelayResponseNormalizer writes `__is<Interface>: false` when `@defer`'d payload omits abstract typename, corrupting DataChecker

Author: jonreading81Created Aug 4, 2026Updated Aug 4, 2026

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:

  1. _normalizeTypeDiscriminator (case 'TypeDiscriminator' in _traverseSelections)
  2. _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):

javascript
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.

javascript
// 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 [email protected]. Also present in current main per source inspection.