packages/relay-runtime/store/RelayResponseNormalizer.js (882 lines of code) (raw):
/**
* 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
* @format
*/
// flowlint ambiguous-object-type:error
'use strict';
import type {ActorIdentifier} from '../multi-actor-environment/ActorIdentifier';
import type {PayloadData} from '../network/RelayNetworkTypes';
import type {
NormalizationActorChange,
NormalizationDefer,
NormalizationFlightField,
NormalizationLinkedField,
NormalizationModuleImport,
NormalizationNode,
NormalizationScalarField,
NormalizationStream,
} from '../util/NormalizationNode';
import type {DataID, Variables} from '../util/RelayRuntimeTypes';
import type {
FollowupPayload,
HandleFieldPayload,
IncrementalDataPlaceholder,
MutableRecordSource,
NormalizationSelector,
ReactFlightPayloadDeserializer,
ReactFlightReachableExecutableDefinitions,
ReactFlightServerErrorHandler,
Record,
RelayResponsePayload,
} from './RelayStoreTypes';
const {
ACTOR_IDENTIFIER_FIELD_NAME,
getActorIdentifierFromPayload,
} = require('../multi-actor-environment/ActorUtils');
const {
ACTOR_CHANGE,
CLIENT_COMPONENT,
CLIENT_EXTENSION,
CONDITION,
DEFER,
FLIGHT_FIELD,
FRAGMENT_SPREAD,
INLINE_FRAGMENT,
LINKED_FIELD,
LINKED_HANDLE,
MODULE_IMPORT,
SCALAR_FIELD,
SCALAR_HANDLE,
STREAM,
TYPE_DISCRIMINATOR,
} = require('../util/RelayConcreteNode');
const RelayFeatureFlags = require('../util/RelayFeatureFlags');
const {generateClientID, isClientID} = require('./ClientID');
const {getLocalVariables} = require('./RelayConcreteVariables');
const RelayModernRecord = require('./RelayModernRecord');
const {createNormalizationSelector} = require('./RelayModernSelector');
const {
REACT_FLIGHT_EXECUTABLE_DEFINITIONS_STORAGE_KEY,
REACT_FLIGHT_TREE_STORAGE_KEY,
REACT_FLIGHT_TYPE_NAME,
refineToReactFlightPayloadData,
} = require('./RelayStoreReactFlightUtils');
const {
ROOT_ID,
ROOT_TYPE,
TYPENAME_KEY,
getArgumentValues,
getHandleStorageKey,
getModuleComponentKey,
getModuleOperationKey,
getStorageKey,
} = require('./RelayStoreUtils');
const {TYPE_SCHEMA_TYPE, generateTypeID} = require('./TypeID');
const areEqual = require('areEqual');
const invariant = require('invariant');
const warning = require('warning');
export type GetDataID = (
fieldValue: {[string]: mixed},
typeName: string,
) => mixed;
export type NormalizationOptions = {|
+getDataID: GetDataID,
+treatMissingFieldsAsNull: boolean,
+path?: $ReadOnlyArray<string>,
+reactFlightPayloadDeserializer?: ?ReactFlightPayloadDeserializer,
+reactFlightServerErrorHandler?: ?ReactFlightServerErrorHandler,
+shouldProcessClientComponents?: ?boolean,
+actorIdentifier?: ?ActorIdentifier,
|};
/**
* Normalizes the results of a query and standard GraphQL response, writing the
* normalized records/fields into the given MutableRecordSource.
*/
function normalize(
recordSource: MutableRecordSource,
selector: NormalizationSelector,
response: PayloadData,
options: NormalizationOptions,
): RelayResponsePayload {
const {dataID, node, variables} = selector;
const normalizer = new RelayResponseNormalizer(
recordSource,
variables,
options,
);
return normalizer.normalizeResponse(node, dataID, response);
}
/**
* @private
*
* Helper for handling payloads.
*/
class RelayResponseNormalizer {
_actorIdentifier: ?ActorIdentifier;
_getDataId: GetDataID;
_handleFieldPayloads: Array<HandleFieldPayload>;
_treatMissingFieldsAsNull: boolean;
_incrementalPlaceholders: Array<IncrementalDataPlaceholder>;
_isClientExtension: boolean;
_isUnmatchedAbstractType: boolean;
_followupPayloads: Array<FollowupPayload>;
_path: Array<string>;
_recordSource: MutableRecordSource;
_variables: Variables;
_reactFlightPayloadDeserializer: ?ReactFlightPayloadDeserializer;
_reactFlightServerErrorHandler: ?ReactFlightServerErrorHandler;
_shouldProcessClientComponents: ?boolean;
constructor(
recordSource: MutableRecordSource,
variables: Variables,
options: NormalizationOptions,
) {
this._actorIdentifier = options.actorIdentifier;
this._getDataId = options.getDataID;
this._handleFieldPayloads = [];
this._treatMissingFieldsAsNull = options.treatMissingFieldsAsNull;
this._incrementalPlaceholders = [];
this._isClientExtension = false;
this._isUnmatchedAbstractType = false;
this._followupPayloads = [];
this._path = options.path ? [...options.path] : [];
this._recordSource = recordSource;
this._variables = variables;
this._reactFlightPayloadDeserializer =
options.reactFlightPayloadDeserializer;
this._reactFlightServerErrorHandler = options.reactFlightServerErrorHandler;
this._shouldProcessClientComponents = options.shouldProcessClientComponents;
}
normalizeResponse(
node: NormalizationNode,
dataID: DataID,
data: PayloadData,
): RelayResponsePayload {
const record = this._recordSource.get(dataID);
invariant(
record,
'RelayResponseNormalizer(): Expected root record `%s` to exist.',
dataID,
);
this._traverseSelections(node, record, data);
return {
errors: null,
fieldPayloads: this._handleFieldPayloads,
incrementalPlaceholders: this._incrementalPlaceholders,
followupPayloads: this._followupPayloads,
source: this._recordSource,
isFinal: false,
};
}
_getVariableValue(name: string): mixed {
invariant(
this._variables.hasOwnProperty(name),
'RelayResponseNormalizer(): Undefined variable `%s`.',
name,
);
return this._variables[name];
}
_getRecordType(data: PayloadData): string {
const typeName = (data: any)[TYPENAME_KEY];
invariant(
typeName != null,
'RelayResponseNormalizer(): Expected a typename for record `%s`.',
JSON.stringify(data, null, 2),
);
return typeName;
}
_traverseSelections(
node: NormalizationNode,
record: Record,
data: PayloadData,
): void {
for (let i = 0; i < node.selections.length; i++) {
const selection = node.selections[i];
switch (selection.kind) {
case SCALAR_FIELD:
case LINKED_FIELD:
this._normalizeField(node, selection, record, data);
break;
case CONDITION:
const conditionValue = Boolean(
this._getVariableValue(selection.condition),
);
if (conditionValue === selection.passingValue) {
this._traverseSelections(selection, record, data);
}
break;
case FRAGMENT_SPREAD: {
const prevVariables = this._variables;
this._variables = getLocalVariables(
this._variables,
selection.fragment.argumentDefinitions,
selection.args,
);
this._traverseSelections(selection.fragment, record, data);
this._variables = prevVariables;
break;
}
case INLINE_FRAGMENT: {
const {abstractKey} = selection;
if (abstractKey == null) {
const typeName = RelayModernRecord.getType(record);
if (typeName === selection.type) {
this._traverseSelections(selection, record, data);
}
} else {
const implementsInterface = data.hasOwnProperty(abstractKey);
const typeName = RelayModernRecord.getType(record);
const typeID = generateTypeID(typeName);
let typeRecord = this._recordSource.get(typeID);
if (typeRecord == null) {
typeRecord = RelayModernRecord.create(typeID, TYPE_SCHEMA_TYPE);
this._recordSource.set(typeID, typeRecord);
}
RelayModernRecord.setValue(
typeRecord,
abstractKey,
implementsInterface,
);
if (implementsInterface) {
this._traverseSelections(selection, record, data);
}
}
break;
}
case TYPE_DISCRIMINATOR: {
const {abstractKey} = selection;
const implementsInterface = data.hasOwnProperty(abstractKey);
const typeName = RelayModernRecord.getType(record);
const typeID = generateTypeID(typeName);
let typeRecord = this._recordSource.get(typeID);
if (typeRecord == null) {
typeRecord = RelayModernRecord.create(typeID, TYPE_SCHEMA_TYPE);
this._recordSource.set(typeID, typeRecord);
}
RelayModernRecord.setValue(
typeRecord,
abstractKey,
implementsInterface,
);
break;
}
case LINKED_HANDLE:
case SCALAR_HANDLE:
const args = selection.args
? getArgumentValues(selection.args, this._variables)
: {};
const fieldKey = getStorageKey(selection, this._variables);
const handleKey = getHandleStorageKey(selection, this._variables);
this._handleFieldPayloads.push({
/* $FlowFixMe[class-object-subtyping] added when improving typing
* for this parameters */
args,
dataID: RelayModernRecord.getDataID(record),
fieldKey,
handle: selection.handle,
handleKey,
handleArgs: selection.handleArgs
? /* $FlowFixMe[class-object-subtyping] added when improving typing
* for this parameters */
getArgumentValues(selection.handleArgs, this._variables)
: {},
});
break;
case MODULE_IMPORT:
this._normalizeModuleImport(node, selection, record, data);
break;
case DEFER:
this._normalizeDefer(selection, record, data);
break;
case STREAM:
this._normalizeStream(selection, record, data);
break;
case CLIENT_EXTENSION:
const isClientExtension = this._isClientExtension;
this._isClientExtension = true;
this._traverseSelections(selection, record, data);
this._isClientExtension = isClientExtension;
break;
case CLIENT_COMPONENT:
if (this._shouldProcessClientComponents === false) {
break;
}
this._traverseSelections(selection.fragment, record, data);
break;
case FLIGHT_FIELD:
if (RelayFeatureFlags.ENABLE_REACT_FLIGHT_COMPONENT_FIELD) {
this._normalizeFlightField(node, selection, record, data);
} else {
throw new Error('Flight fields are not yet supported.');
}
break;
case ACTOR_CHANGE:
this._normalizeActorChange(node, selection, record, data);
break;
default:
(selection: empty);
invariant(
false,
'RelayResponseNormalizer(): Unexpected ast kind `%s`.',
selection.kind,
);
}
}
}
_normalizeDefer(
defer: NormalizationDefer,
record: Record,
data: PayloadData,
) {
const isDeferred = defer.if === null || this._getVariableValue(defer.if);
if (__DEV__) {
warning(
typeof isDeferred === 'boolean',
'RelayResponseNormalizer: Expected value for @defer `if` argument to ' +
'be a boolean, got `%s`.',
isDeferred,
);
}
if (isDeferred === false) {
// If defer is disabled there will be no additional response chunk:
// normalize the data already present.
this._traverseSelections(defer, record, data);
} else {
// Otherwise data *for this selection* should not be present: enqueue
// metadata to process the subsequent response chunk.
this._incrementalPlaceholders.push({
kind: 'defer',
data,
label: defer.label,
path: [...this._path],
selector: createNormalizationSelector(
defer,
RelayModernRecord.getDataID(record),
this._variables,
),
typeName: RelayModernRecord.getType(record),
actorIdentifier: this._actorIdentifier,
});
}
}
_normalizeStream(
stream: NormalizationStream,
record: Record,
data: PayloadData,
) {
// Always normalize regardless of whether streaming is enabled or not,
// this populates the initial array value (including any items when
// initial_count > 0).
this._traverseSelections(stream, record, data);
const isStreamed = stream.if === null || this._getVariableValue(stream.if);
if (__DEV__) {
warning(
typeof isStreamed === 'boolean',
'RelayResponseNormalizer: Expected value for @stream `if` argument ' +
'to be a boolean, got `%s`.',
isStreamed,
);
}
if (isStreamed === true) {
// If streaming is enabled, *also* emit metadata to process any
// response chunks that may be delivered.
this._incrementalPlaceholders.push({
kind: 'stream',
label: stream.label,
path: [...this._path],
parentID: RelayModernRecord.getDataID(record),
node: stream,
variables: this._variables,
actorIdentifier: this._actorIdentifier,
});
}
}
_normalizeModuleImport(
parent: NormalizationNode,
moduleImport: NormalizationModuleImport,
record: Record,
data: PayloadData,
) {
invariant(
typeof data === 'object' && data,
'RelayResponseNormalizer: Expected data for @module to be an object.',
);
const typeName: string = RelayModernRecord.getType(record);
const componentKey = getModuleComponentKey(moduleImport.documentName);
const componentReference = data[componentKey];
RelayModernRecord.setValue(
record,
componentKey,
componentReference ?? null,
);
const operationKey = getModuleOperationKey(moduleImport.documentName);
const operationReference = data[operationKey];
RelayModernRecord.setValue(
record,
operationKey,
operationReference ?? null,
);
if (operationReference != null) {
this._followupPayloads.push({
kind: 'ModuleImportPayload',
args: moduleImport.args,
data,
dataID: RelayModernRecord.getDataID(record),
operationReference,
path: [...this._path],
typeName,
variables: this._variables,
actorIdentifier: this._actorIdentifier,
});
}
}
_normalizeField(
parent: NormalizationNode,
selection: NormalizationLinkedField | NormalizationScalarField,
record: Record,
data: PayloadData,
) {
invariant(
typeof data === 'object' && data,
'writeField(): Expected data for field `%s` to be an object.',
selection.name,
);
const responseKey = selection.alias || selection.name;
const storageKey = getStorageKey(selection, this._variables);
const fieldValue = data[responseKey];
if (fieldValue == null) {
if (fieldValue === undefined) {
// Fields may be missing in the response in two main cases:
// - Inside a client extension: the server will not generally return
// values for these fields, but a local update may provide them.
// - Inside an abstract type refinement where the concrete type does
// not conform to the interface/union.
// However an otherwise-required field may also be missing if the server
// is configured to skip fields with `null` values, in which case the
// client is assumed to be correctly configured with
// treatMissingFieldsAsNull=true.
const isOptionalField =
this._isClientExtension || this._isUnmatchedAbstractType;
if (isOptionalField) {
// Field not expected to exist regardless of whether the server is pruning null
// fields or not.
return;
} else if (!this._treatMissingFieldsAsNull) {
// Not optional and the server is not pruning null fields: field is expected
// to be present
if (__DEV__) {
warning(
false,
'RelayResponseNormalizer: Payload did not contain a value ' +
'for field `%s: %s`. Check that you are parsing with the same ' +
'query that was used to fetch the payload.',
responseKey,
storageKey,
);
}
return;
}
}
if (__DEV__) {
if (selection.kind === SCALAR_FIELD) {
this._validateConflictingFieldsWithIdenticalId(
record,
storageKey,
// When using `treatMissingFieldsAsNull` the conflicting validation raises a false positive
// because the value is set using `null` but validated using `fieldValue` which at this point
// will be `undefined`.
// Setting this to `null` matches the value that we actually set to the `fieldValue`.
null,
);
}
}
RelayModernRecord.setValue(record, storageKey, null);
return;
}
if (selection.kind === SCALAR_FIELD) {
if (__DEV__) {
this._validateConflictingFieldsWithIdenticalId(
record,
storageKey,
fieldValue,
);
}
RelayModernRecord.setValue(record, storageKey, fieldValue);
} else if (selection.kind === LINKED_FIELD) {
this._path.push(responseKey);
if (selection.plural) {
this._normalizePluralLink(selection, record, storageKey, fieldValue);
} else {
this._normalizeLink(selection, record, storageKey, fieldValue);
}
this._path.pop();
} else {
(selection: empty);
invariant(
false,
'RelayResponseNormalizer(): Unexpected ast kind `%s` during normalization.',
selection.kind,
);
}
}
_normalizeActorChange(
parent: NormalizationNode,
selection: NormalizationActorChange,
record: Record,
data: PayloadData,
) {
const field = selection.linkedField;
invariant(
typeof data === 'object' && data,
'_normalizeActorChange(): Expected data for field `%s` to be an object.',
field.name,
);
const responseKey = field.alias || field.name;
const storageKey = getStorageKey(field, this._variables);
const fieldValue = data[responseKey];
if (fieldValue == null) {
if (fieldValue === undefined) {
const isOptionalField =
this._isClientExtension || this._isUnmatchedAbstractType;
if (isOptionalField) {
return;
} else if (!this._treatMissingFieldsAsNull) {
if (__DEV__) {
warning(
false,
'RelayResponseNormalizer: Payload did not contain a value ' +
'for field `%s: %s`. Check that you are parsing with the same ' +
'query that was used to fetch the payload.',
responseKey,
storageKey,
);
}
return;
}
}
RelayModernRecord.setValue(record, storageKey, null);
return;
}
const actorIdentifier = getActorIdentifierFromPayload(fieldValue);
if (actorIdentifier == null) {
if (__DEV__) {
warning(
false,
'RelayResponseNormalizer: Payload did not contain a value ' +
'for field `%s`. Check that you are parsing with the same ' +
'query that was used to fetch the payload. Payload is `%s`.',
ACTOR_IDENTIFIER_FIELD_NAME,
JSON.stringify(fieldValue, null, 2),
);
}
RelayModernRecord.setValue(record, storageKey, null);
return;
}
// $FlowFixMe[incompatible-call]
const typeName = field.concreteType ?? this._getRecordType(fieldValue);
const nextID =
this._getDataId(
// $FlowFixMe[incompatible-call]
fieldValue,
typeName,
) ||
RelayModernRecord.getLinkedRecordID(record, storageKey) ||
generateClientID(RelayModernRecord.getDataID(record), storageKey);
invariant(
typeof nextID === 'string',
'RelayResponseNormalizer: Expected id on field `%s` to be a string.',
storageKey,
);
RelayModernRecord.setActorLinkedRecordID(
record,
storageKey,
actorIdentifier,
nextID,
);
this._followupPayloads.push({
kind: 'ActorPayload',
data: (fieldValue: $FlowFixMe),
dataID: nextID,
path: [...this._path, responseKey],
typeName,
variables: this._variables,
node: field,
actorIdentifier,
});
}
_normalizeFlightField(
parent: NormalizationNode,
selection: NormalizationFlightField,
record: Record,
data: PayloadData,
) {
const responseKey = selection.alias || selection.name;
const storageKey = getStorageKey(selection, this._variables);
const fieldValue = data[responseKey];
if (fieldValue == null) {
if (fieldValue === undefined) {
// Flight field may be missing in the response if:
// - It is inside an abstract type refinement where the concrete type does
// not conform to the interface/union.
// However an otherwise-required field may also be missing if the server
// is configured to skip fields with `null` values, in which case the
// client is assumed to be correctly configured with
// treatMissingFieldsAsNull=true.
if (this._isUnmatchedAbstractType) {
// Field not expected to exist regardless of whether the server is pruning null
// fields or not.
return;
} else {
// Not optional and the server is not pruning null fields: field is expected
// to be present
invariant(
this._treatMissingFieldsAsNull,
'RelayResponseNormalizer: Payload did not contain a value for ' +
'field `%s: %s`. Check that you are parsing with the same ' +
'query that was used to fetch the payload.',
responseKey,
storageKey,
);
}
}
RelayModernRecord.setValue(record, storageKey, null);
return;
}
const reactFlightPayload = refineToReactFlightPayloadData(fieldValue);
const reactFlightPayloadDeserializer = this._reactFlightPayloadDeserializer;
invariant(
reactFlightPayload != null,
'RelayResponseNormalizer: Expected React Flight payload data to be an ' +
'object with `status`, tree`, `queries` and `errors` properties, got ' +
'`%s`.',
fieldValue,
);
invariant(
typeof reactFlightPayloadDeserializer === 'function',
'RelayResponseNormalizer: Expected reactFlightPayloadDeserializer to ' +
'be a function, got `%s`.',
reactFlightPayloadDeserializer,
);
if (reactFlightPayload.errors.length > 0) {
if (typeof this._reactFlightServerErrorHandler === 'function') {
this._reactFlightServerErrorHandler(
reactFlightPayload.status,
reactFlightPayload.errors,
);
} else {
warning(
false,
'RelayResponseNormalizer: Received server errors for field `%s`.\n\n' +
'%s\n%s',
responseKey,
reactFlightPayload.errors[0].message,
reactFlightPayload.errors[0].stack,
);
}
}
const reactFlightID = generateClientID(
RelayModernRecord.getDataID(record),
getStorageKey(selection, this._variables),
);
let reactFlightClientResponseRecord = this._recordSource.get(reactFlightID);
if (reactFlightClientResponseRecord == null) {
reactFlightClientResponseRecord = RelayModernRecord.create(
reactFlightID,
REACT_FLIGHT_TYPE_NAME,
);
this._recordSource.set(reactFlightID, reactFlightClientResponseRecord);
}
if (reactFlightPayload.tree == null) {
// This typically indicates that a fatal server error prevented rows from
// being written. When this occurs, we should not continue normalization of
// the Flight field because the row response is malformed.
//
// Receiving empty rows is OK because it can indicate the start of a stream.
warning(
false,
'RelayResponseNormalizer: Expected `tree` not to be null. This ' +
'typically indicates that a fatal server error prevented any Server ' +
'Component rows from being written.',
);
// We create the flight record with a null value for the tree
// and empty reachable definitions
RelayModernRecord.setValue(
reactFlightClientResponseRecord,
REACT_FLIGHT_TREE_STORAGE_KEY,
null,
);
RelayModernRecord.setValue(
reactFlightClientResponseRecord,
REACT_FLIGHT_EXECUTABLE_DEFINITIONS_STORAGE_KEY,
[],
);
RelayModernRecord.setLinkedRecordID(record, storageKey, reactFlightID);
return;
}
// We store the deserialized reactFlightClientResponse in a separate
// record and link it to the parent record. This is so we can GC the Flight
// tree later even if the parent record is still reachable.
const reactFlightClientResponse = reactFlightPayloadDeserializer(
reactFlightPayload.tree,
);
RelayModernRecord.setValue(
reactFlightClientResponseRecord,
REACT_FLIGHT_TREE_STORAGE_KEY,
reactFlightClientResponse,
);
const reachableExecutableDefinitions: Array<ReactFlightReachableExecutableDefinitions> =
[];
for (const query of reactFlightPayload.queries) {
if (query.response.data != null) {
this._followupPayloads.push({
kind: 'ModuleImportPayload',
args: null,
data: query.response.data,
dataID: ROOT_ID,
operationReference: query.module,
path: [],
typeName: ROOT_TYPE,
variables: query.variables,
actorIdentifier: this._actorIdentifier,
});
}
reachableExecutableDefinitions.push({
module: query.module,
variables: query.variables,
});
}
for (const fragment of reactFlightPayload.fragments) {
if (fragment.response.data != null) {
this._followupPayloads.push({
kind: 'ModuleImportPayload',
args: null,
data: fragment.response.data,
dataID: fragment.__id,
operationReference: fragment.module,
path: [],
typeName: fragment.__typename,
variables: fragment.variables,
actorIdentifier: this._actorIdentifier,
});
}
reachableExecutableDefinitions.push({
module: fragment.module,
variables: fragment.variables,
});
}
RelayModernRecord.setValue(
reactFlightClientResponseRecord,
REACT_FLIGHT_EXECUTABLE_DEFINITIONS_STORAGE_KEY,
reachableExecutableDefinitions,
);
RelayModernRecord.setLinkedRecordID(record, storageKey, reactFlightID);
}
_normalizeLink(
field: NormalizationLinkedField,
record: Record,
storageKey: string,
fieldValue: mixed,
): void {
invariant(
typeof fieldValue === 'object' && fieldValue,
'RelayResponseNormalizer: Expected data for field `%s` to be an object.',
storageKey,
);
const nextID =
this._getDataId(
// $FlowFixMe[incompatible-variance]
fieldValue,
// $FlowFixMe[incompatible-variance]
field.concreteType ?? this._getRecordType(fieldValue),
) ||
// Reuse previously generated client IDs
RelayModernRecord.getLinkedRecordID(record, storageKey) ||
generateClientID(RelayModernRecord.getDataID(record), storageKey);
invariant(
typeof nextID === 'string',
'RelayResponseNormalizer: Expected id on field `%s` to be a string.',
storageKey,
);
if (__DEV__) {
this._validateConflictingLinkedFieldsWithIdenticalId(
record,
RelayModernRecord.getLinkedRecordID(record, storageKey),
nextID,
storageKey,
);
}
RelayModernRecord.setLinkedRecordID(record, storageKey, nextID);
let nextRecord = this._recordSource.get(nextID);
if (!nextRecord) {
// $FlowFixMe[incompatible-variance]
const typeName = field.concreteType || this._getRecordType(fieldValue);
nextRecord = RelayModernRecord.create(nextID, typeName);
this._recordSource.set(nextID, nextRecord);
} else if (__DEV__) {
this._validateRecordType(nextRecord, field, fieldValue);
}
// $FlowFixMe[incompatible-variance]
this._traverseSelections(field, nextRecord, fieldValue);
}
_normalizePluralLink(
field: NormalizationLinkedField,
record: Record,
storageKey: string,
fieldValue: mixed,
): void {
invariant(
Array.isArray(fieldValue),
'RelayResponseNormalizer: Expected data for field `%s` to be an array ' +
'of objects.',
storageKey,
);
const prevIDs = RelayModernRecord.getLinkedRecordIDs(record, storageKey);
const nextIDs = [];
fieldValue.forEach((item, nextIndex) => {
// validate response data
if (item == null) {
nextIDs.push(item);
return;
}
this._path.push(String(nextIndex));
invariant(
typeof item === 'object',
'RelayResponseNormalizer: Expected elements for field `%s` to be ' +
'objects.',
storageKey,
);
const nextID =
this._getDataId(
// $FlowFixMe[incompatible-variance]
item,
// $FlowFixMe[incompatible-variance]
field.concreteType ?? this._getRecordType(item),
) ||
(prevIDs && prevIDs[nextIndex]) || // Reuse previously generated client IDs:
generateClientID(
RelayModernRecord.getDataID(record),
storageKey,
nextIndex,
);
invariant(
typeof nextID === 'string',
'RelayResponseNormalizer: Expected id of elements of field `%s` to ' +
'be strings.',
storageKey,
);
nextIDs.push(nextID);
let nextRecord = this._recordSource.get(nextID);
if (!nextRecord) {
// $FlowFixMe[incompatible-variance]
const typeName = field.concreteType || this._getRecordType(item);
nextRecord = RelayModernRecord.create(nextID, typeName);
this._recordSource.set(nextID, nextRecord);
} else if (__DEV__) {
this._validateRecordType(nextRecord, field, item);
}
// NOTE: the check to strip __DEV__ code only works for simple
// `if (__DEV__)`
if (__DEV__) {
if (prevIDs) {
this._validateConflictingLinkedFieldsWithIdenticalId(
record,
prevIDs[nextIndex],
nextID,
storageKey,
);
}
}
// $FlowFixMe[incompatible-variance]
this._traverseSelections(field, nextRecord, item);
this._path.pop();
});
RelayModernRecord.setLinkedRecordIDs(record, storageKey, nextIDs);
}
/**
* Warns if the type of the record does not match the type of the field/payload.
*/
_validateRecordType(
record: Record,
field: NormalizationLinkedField,
payload: Object,
): void {
const typeName = field.concreteType ?? this._getRecordType(payload);
const dataID = RelayModernRecord.getDataID(record);
warning(
(isClientID(dataID) && dataID !== ROOT_ID) ||
RelayModernRecord.getType(record) === typeName,
'RelayResponseNormalizer: Invalid record `%s`. Expected %s to be ' +
'consistent, but the record was assigned conflicting types `%s` ' +
'and `%s`. The GraphQL server likely violated the globally unique ' +
'id requirement by returning the same id for different objects.',
dataID,
TYPENAME_KEY,
RelayModernRecord.getType(record),
typeName,
);
}
/**
* Warns if a single response contains conflicting fields with the same id
*/
_validateConflictingFieldsWithIdenticalId(
record: Record,
storageKey: string,
fieldValue: mixed,
): void {
// NOTE: Only call this function in DEV
if (__DEV__) {
const dataID = RelayModernRecord.getDataID(record);
var previousValue = RelayModernRecord.getValue(record, storageKey);
warning(
storageKey === TYPENAME_KEY ||
previousValue === undefined ||
areEqual(previousValue, fieldValue),
'RelayResponseNormalizer: Invalid record. The record contains two ' +
'instances of the same id: `%s` with conflicting field, %s and its values: %s and %s. ' +
'If two fields are different but share ' +
'the same id, one field will overwrite the other.',
dataID,
storageKey,
previousValue,
fieldValue,
);
}
}
/**
* Warns if a single response contains conflicting fields with the same id
*/
_validateConflictingLinkedFieldsWithIdenticalId(
record: Record,
prevID: ?DataID,
nextID: DataID,
storageKey: string,
): void {
// NOTE: Only call this function in DEV
if (__DEV__) {
warning(
prevID === undefined || prevID === nextID,
'RelayResponseNormalizer: Invalid record. The record contains ' +
'references to the conflicting field, %s and its id values: %s and %s. ' +
'We need to make sure that the record the field points ' +
'to remains consistent or one field will overwrite the other.',
storageKey,
prevID,
nextID,
);
}
}
}
module.exports = {
normalize,
};