Skip to content

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

Description

@jonreading81

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):

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions