relay-runtime
Version:
A core runtime for building GraphQL-driven applications.
1,459 lines (1,370 loc) • 57.6 kB
Flow
/**
* 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 strict-local
* @format
* @oncall relay
*/
'use strict';
import type {Result} from '../experimental';
import type {
CatchFieldTo,
ReaderActorChange,
ReaderAliasedInlineFragmentSpread,
ReaderCatchField,
ReaderClientEdge,
ReaderFragment,
ReaderFragmentSpread,
ReaderInlineDataFragmentSpread,
ReaderInlineFragment,
ReaderLinkedField,
ReaderModuleImport,
ReaderNode,
ReaderRelayLiveResolver,
ReaderRelayResolver,
ReaderRequiredField,
ReaderScalarField,
ReaderSelection,
} from '../util/ReaderNode';
import type {DataID, Variables} from '../util/RelayRuntimeTypes';
import type {
ClientEdgeTraversalInfo,
DataIDSet,
ErrorResponseField,
ErrorResponseFields,
MissingClientEdgeRequestInfo,
Record,
RecordSource,
RequestDescriptor,
ResolverContext,
SelectorData,
SingularReaderSelector,
Snapshot,
} from './RelayStoreTypes';
import type {Arguments} from './RelayStoreUtils';
import type {EvaluationResult, ResolverCache} from './ResolverCache';
const {
isSuspenseSentinel,
} = require('./live-resolvers/LiveResolverSuspenseSentinel');
const RelayConcreteVariables = require('./RelayConcreteVariables');
const RelayModernRecord = require('./RelayModernRecord');
const {
CLIENT_EDGE_TRAVERSAL_PATH,
FRAGMENT_OWNER_KEY,
FRAGMENT_PROP_NAME_KEY,
FRAGMENTS_KEY,
ID_KEY,
MODULE_COMPONENT_KEY,
ROOT_ID,
getArgumentValues,
getModuleComponentKey,
getStorageKey,
} = require('./RelayStoreUtils');
const {NoopResolverCache} = require('./ResolverCache');
const {
RESOLVER_FRAGMENT_ERRORED_SENTINEL,
withResolverContext,
} = require('./ResolverFragments');
const {generateTypeID} = require('./TypeID');
const invariant = require('invariant');
function read(
recordSource: RecordSource,
selector: SingularReaderSelector,
resolverCache?: ResolverCache,
resolverContext?: ResolverContext,
): Snapshot {
const reader = new RelayReader(
recordSource,
selector,
resolverCache ?? new NoopResolverCache(),
resolverContext,
);
return reader.read();
}
/**
* @private
*/
class RelayReader {
_clientEdgeTraversalPath: Array<ClientEdgeTraversalInfo | null>;
_isMissingData: boolean;
_missingClientEdges: Array<MissingClientEdgeRequestInfo>;
_missingLiveResolverFields: Array<DataID>;
_isWithinUnmatchedTypeRefinement: boolean;
_errorResponseFields: ?ErrorResponseFields;
_owner: RequestDescriptor;
// Exec time resolvers are run before reaching the Relay store so the store already contains
// the normalized data; the same as if the data were sent from the server. However, since a
// resolver could be used at read time or exec time in different queries, the reader AST for
// a resolver is the read time AST. At runtime, this flag is used to ignore the extra
// information in the read time resolver AST and use the "standard", non-resolver read paths
_useExecTimeResolvers: boolean;
_recordSource: RecordSource;
_seenRecords: DataIDSet;
_updatedDataIDs: DataIDSet;
_selector: SingularReaderSelector;
_variables: Variables;
_resolverCache: ResolverCache;
_fragmentName: string;
_resolverContext: ?ResolverContext;
constructor(
recordSource: RecordSource,
selector: SingularReaderSelector,
resolverCache: ResolverCache,
resolverContext: ?ResolverContext,
) {
this._clientEdgeTraversalPath = selector.clientEdgeTraversalPath?.length
? [...selector.clientEdgeTraversalPath]
: [];
this._missingClientEdges = [];
this._missingLiveResolverFields = [];
this._isMissingData = false;
this._isWithinUnmatchedTypeRefinement = false;
this._errorResponseFields = null;
this._owner = selector.owner;
this._useExecTimeResolvers =
this._owner.node.operation.use_exec_time_resolvers ?? false;
this._recordSource = recordSource;
this._seenRecords = new Set();
this._selector = selector;
this._variables = selector.variables;
this._resolverCache = resolverCache;
this._fragmentName = selector.node.name;
this._updatedDataIDs = new Set();
this._resolverContext = resolverContext;
}
read(): Snapshot {
const {node, dataID, isWithinUnmatchedTypeRefinement} = this._selector;
const {abstractKey} = node;
const record = this._recordSource.get(dataID);
// Relay historically allowed child fragments to be read even if the root object
// did not match the type of the fragment: either the root object has a different
// concrete type than the fragment (for concrete fragments) or the root object does
// not conform to the interface/union for abstract fragments.
// For suspense purposes, however, we want to accurately compute whether any data
// is missing: but if the fragment type doesn't match (or a parent type didn't
// match), then no data is expected to be present.
// By default data is expected to be present unless this selector was read out
// from within a non-matching type refinement in a parent fragment:
let isDataExpectedToBePresent = !isWithinUnmatchedTypeRefinement;
// If this is a concrete fragment and the concrete type of the record does not
// match, then no data is expected to be present.
if (isDataExpectedToBePresent && abstractKey == null && record != null) {
if (!this._recordMatchesTypeCondition(record, node.type)) {
isDataExpectedToBePresent = false;
}
}
// If this is an abstract fragment (and the precise refinement GK is enabled)
// then data is only expected to be present if the record type is known to
// implement the interface. If we aren't sure whether the record implements
// the interface, that itself constitutes "expected" data being missing.
if (isDataExpectedToBePresent && abstractKey != null && record != null) {
const implementsInterface = this._implementsInterface(
record,
abstractKey,
);
if (implementsInterface === false) {
// Type known to not implement the interface
isDataExpectedToBePresent = false;
}
}
this._isWithinUnmatchedTypeRefinement = !isDataExpectedToBePresent;
let data = this._traverse(node, dataID, null);
// If the fragment/operation was marked with @catch, we need to handle any
// errors that were encountered while reading the fields within it.
const catchTo = this._selector.node.metadata?.catchTo;
if (catchTo != null) {
data = this._catchErrors(data, catchTo, null) as $FlowFixMe;
}
if (this._updatedDataIDs.size > 0) {
this._resolverCache.notifyUpdatedSubscribers(this._updatedDataIDs);
this._updatedDataIDs.clear();
}
return {
data,
isMissingData: this._isMissingData && isDataExpectedToBePresent,
missingClientEdges: this._missingClientEdges.length
? this._missingClientEdges
: null,
missingLiveResolverFields: this._missingLiveResolverFields,
seenRecords: this._seenRecords,
selector: this._selector,
errorResponseFields: this._errorResponseFields,
};
}
_maybeAddErrorResponseFields(record: Record, storageKey: string): void {
const errors = RelayModernRecord.getErrors(record, storageKey);
if (errors == null) {
return;
}
const owner = this._fragmentName;
if (this._errorResponseFields == null) {
this._errorResponseFields = [];
}
for (const error of errors) {
this._errorResponseFields.push({
kind: 'relay_field_payload.error',
owner,
fieldPath: (error.path ?? []).join('.'),
error,
shouldThrow: this._selector.node.metadata?.throwOnFieldError ?? false,
handled: false,
});
}
}
_markDataAsMissing(fieldName: string): void {
if (this._isWithinUnmatchedTypeRefinement) {
return;
}
if (this._errorResponseFields == null) {
this._errorResponseFields = [];
}
// we will add the path later
const owner = this._fragmentName;
this._errorResponseFields.push(
this._selector.node.metadata?.throwOnFieldError ?? false
? {
kind: 'missing_expected_data.throw',
owner,
fieldPath: fieldName,
handled: false,
}
: {kind: 'missing_expected_data.log', owner, fieldPath: fieldName},
);
this._isMissingData = true;
if (this._clientEdgeTraversalPath.length) {
const top =
this._clientEdgeTraversalPath[this._clientEdgeTraversalPath.length - 1];
// Top can be null if we've traversed past a client edge into an ordinary
// client extension field; we never want to fetch in response to missing
// data off of a client extension field.
if (top !== null) {
this._missingClientEdges.push({
request: top.readerClientEdge.operation,
clientEdgeDestinationID: top.clientEdgeDestinationID,
});
}
}
}
_traverse(
node: ReaderNode,
dataID: DataID,
prevData: ?SelectorData,
): ?SelectorData {
const record = this._recordSource.get(dataID);
this._seenRecords.add(dataID);
if (record == null) {
if (record === undefined) {
this._markDataAsMissing('<record>');
}
return record;
}
const data = prevData || {};
const hadRequiredData = this._traverseSelections(
node.selections,
record,
data,
);
return hadRequiredData ? data : null;
}
_getVariableValue(name: string): mixed {
invariant(
this._variables.hasOwnProperty(name),
'RelayReader(): Undefined variable `%s`.',
name,
);
return this._variables[name];
}
_maybeReportUnexpectedNull(selection: ReaderRequiredField) {
if (selection.action === 'NONE') {
return;
}
const owner = this._fragmentName;
if (this._errorResponseFields == null) {
this._errorResponseFields = [];
}
let fieldName: string;
if (selection.field.linkedField != null) {
fieldName =
selection.field.linkedField.alias ?? selection.field.linkedField.name;
} else {
fieldName = selection.field.alias ?? selection.field.name;
}
switch (selection.action) {
case 'THROW':
this._errorResponseFields.push({
kind: 'missing_required_field.throw',
fieldPath: fieldName,
owner,
handled: false,
});
return;
case 'LOG':
this._errorResponseFields.push({
kind: 'missing_required_field.log',
fieldPath: fieldName,
owner,
});
return;
default:
(selection.action: empty);
}
}
_handleRequiredFieldValue(
selection: ReaderRequiredField,
value: mixed,
): boolean /*should continue to siblings*/ {
if (value == null) {
this._maybeReportUnexpectedNull(selection);
// We are going to throw, or our parent is going to get nulled out.
// Either way, sibling values are going to be ignored, so we can
// bail early here as an optimization.
return false;
}
return true;
}
/**
* Fields, aliased inline fragments, fragments and operations with `@catch`
* directives must handle the case that errors were encountered while reading
* any fields within them.
*
* 1. Before traversing into the selection(s) marked as `@catch`, the caller
* stores the previous field errors (`this._errorResponseFields`) in a
* variable.
* 2. After traversing into the selection(s) marked as `@catch`, the caller
* calls this method with the resulting value, the `to` value from the
* `@catch` directive, and the previous field errors.
*
* This method will then:
*
* 1. Compute the correct value to return based on any errors encountered and the supplied `to` type.
* 2. Mark any errors encountered within the `@catch` as "handled" to ensure they don't cause the reader to throw.
* 3. Merge any errors encountered within the `@catch` with the previous field errors.
*/
_catchErrors<T>(
_value: T,
to: CatchFieldTo,
previousResponseFields: ?ErrorResponseFields,
): ?T | Result<T, mixed> {
let value: T | null | Result<T, mixed> = _value;
switch (to) {
case 'RESULT':
value = this._asResult(_value);
break;
case 'NULL':
if (
this._errorResponseFields != null &&
this._errorResponseFields.length > 0
) {
value = null;
}
break;
default:
(to: empty);
}
const childrenErrorResponseFields = this._errorResponseFields;
this._errorResponseFields = previousResponseFields;
// Merge any errors encountered within the @catch with the previous field
// errors, but mark them as "handled" first.
if (childrenErrorResponseFields != null) {
if (this._errorResponseFields == null) {
this._errorResponseFields = [];
}
for (let i = 0; i < childrenErrorResponseFields.length; i++) {
// We mark any errors encountered within the @catch as "handled"
// to ensure that they don't cause the reader to throw, but can
// still be logged.
this._errorResponseFields.push(
markFieldErrorHasHandled(childrenErrorResponseFields[i]),
);
}
}
return value;
}
/**
* Convert a value into a Result object based on the presence of errors in the
* `this._errorResponseFields` array.
*
* **Note**: This method does _not_ mark errors as handled. It is the caller's
* responsibility to ensure that errors are marked as handled.
*/
_asResult<T>(value: T): Result<T, mixed> {
if (
this._errorResponseFields == null ||
this._errorResponseFields.length === 0
) {
return {ok: true, value};
}
// TODO: Should we be hiding log level events here?
const errors = this._errorResponseFields
.map(error => {
switch (error.kind) {
case 'relay_field_payload.error':
const {message, ...displayError} = error.error;
return displayError;
case 'missing_expected_data.throw':
case 'missing_expected_data.log':
return {
path: error.fieldPath.split('.'),
};
case 'relay_resolver.error':
return {
message: `Relay: Error in resolver for field at ${error.fieldPath} in ${error.owner}`,
};
case 'missing_required_field.throw':
// If we have a nested @required(THROW) that will throw,
// we want to catch that error and provide it
return {
message: `Relay: Missing @required value at path '${error.fieldPath}' in '${error.owner}'.`,
};
case 'missing_required_field.log':
// For backwards compatibility, we don't surface log level missing required fields
return null;
default:
(error.kind: empty);
invariant(
false,
'Unexpected error errorResponseField kind: %s',
error.kind,
);
}
})
.filter(Boolean);
return {ok: false, errors};
}
_traverseSelections(
selections: $ReadOnlyArray<ReaderSelection>,
record: Record,
data: SelectorData,
): boolean /* had all expected data */ {
for (let i = 0; i < selections.length; i++) {
const selection = selections[i];
switch (selection.kind) {
case 'RequiredField':
const requiredFieldValue = this._readClientSideDirectiveField(
selection,
record,
data,
);
if (!this._handleRequiredFieldValue(selection, requiredFieldValue)) {
return false;
}
break;
case 'CatchField': {
const previousResponseFields = this._errorResponseFields;
this._errorResponseFields = null;
const catchFieldValue = this._readClientSideDirectiveField(
selection,
record,
data,
);
const field = selection.field?.backingField ?? selection.field;
const fieldName = field?.alias ?? field?.name;
// ReaderClientExtension doesn't have `alias` or `name`
// so we don't support this yet
invariant(
fieldName != null,
"Couldn't determine field name for this field. It might be a ReaderClientExtension - which is not yet supported.",
);
data[fieldName] = this._catchErrors(
catchFieldValue,
selection.to,
previousResponseFields,
);
break;
}
case 'ScalarField':
this._readScalar(selection, record, data);
break;
case 'LinkedField':
if (selection.plural) {
this._readPluralLink(selection, record, data);
} else {
this._readLink(selection, record, data);
}
break;
case 'Condition':
const conditionValue = Boolean(
this._getVariableValue(selection.condition),
);
if (conditionValue === selection.passingValue) {
const hasExpectedData = this._traverseSelections(
selection.selections,
record,
data,
);
if (!hasExpectedData) {
return false;
}
}
break;
case 'InlineFragment': {
const hasExpectedData = this._readInlineFragment(
selection,
record,
data,
false,
);
if (hasExpectedData === false) {
// We are missing @required data, so we bubble up.
return false;
}
break;
}
case 'RelayLiveResolver':
case 'RelayResolver': {
if (this._useExecTimeResolvers) {
this._readScalar(selection, record, data);
} else {
this._readResolverField(selection, record, data);
}
break;
}
case 'FragmentSpread':
this._createFragmentPointer(selection, record, data);
break;
case 'AliasedInlineFragmentSpread': {
this._readAliasedInlineFragment(selection, record, data);
break;
}
case 'ModuleImport':
this._readModuleImport(selection, record, data);
break;
case 'InlineDataFragmentSpread':
this._createInlineDataOrResolverFragmentPointer(
selection,
record,
data,
);
break;
case 'Defer':
case 'ClientExtension': {
const isMissingData = this._isMissingData;
const alreadyMissingClientEdges = this._missingClientEdges.length;
this._clientEdgeTraversalPath.push(null);
const hasExpectedData = this._traverseSelections(
selection.selections,
record,
data,
);
// The only case where we want to suspend due to missing data off of
// a client extension is if we reached a client edge that we might be
// able to fetch, or there is a missing data in one of the live resolvers.
this._isMissingData =
isMissingData ||
this._missingClientEdges.length > alreadyMissingClientEdges ||
this._missingLiveResolverFields.length > 0;
this._clientEdgeTraversalPath.pop();
if (!hasExpectedData) {
return false;
}
break;
}
case 'Stream': {
const hasExpectedData = this._traverseSelections(
selection.selections,
record,
data,
);
if (!hasExpectedData) {
return false;
}
break;
}
case 'ActorChange':
this._readActorChange(selection, record, data);
break;
case 'ClientEdgeToClientObject':
case 'ClientEdgeToServerObject':
if (
this._useExecTimeResolvers &&
(selection.backingField.kind === 'RelayResolver' ||
selection.backingField.kind === 'RelayLiveResolver')
) {
const {linkedField} = selection;
if (linkedField.plural) {
this._readPluralLink(linkedField, record, data);
} else {
this._readLink(linkedField, record, data);
}
} else {
this._readClientEdge(selection, record, data);
}
break;
default:
(selection: empty);
invariant(
false,
'RelayReader(): Unexpected ast kind `%s`.',
selection.kind,
);
}
}
return true;
}
_readClientSideDirectiveField(
selection: ReaderRequiredField | ReaderCatchField,
record: Record,
data: SelectorData,
): ?mixed {
switch (selection.field.kind) {
case 'ScalarField':
return this._readScalar(selection.field, record, data);
case 'LinkedField':
if (selection.field.plural) {
return this._readPluralLink(selection.field, record, data);
} else {
return this._readLink(selection.field, record, data);
}
case 'RelayResolver':
return this._readResolverField(selection.field, record, data);
case 'RelayLiveResolver':
return this._readResolverField(selection.field, record, data);
case 'ClientEdgeToClientObject':
case 'ClientEdgeToServerObject':
return this._readClientEdge(selection.field, record, data);
case 'AliasedInlineFragmentSpread':
return this._readAliasedInlineFragment(selection.field, record, data);
default:
(selection.field.kind: empty);
invariant(
false,
'RelayReader(): Unexpected ast kind `%s`.',
selection.field.kind,
);
}
}
_readResolverField(
field: ReaderRelayResolver | ReaderRelayLiveResolver,
record: Record,
data: SelectorData,
): mixed {
const parentRecordID = RelayModernRecord.getDataID(record);
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
const result = this._readResolverFieldImpl(field, parentRecordID);
const fieldName = field.alias ?? field.name;
this._prependPreviousErrors(prevErrors, fieldName);
data[fieldName] = result;
return result;
}
_readResolverFieldImpl(
field: ReaderRelayResolver | ReaderRelayLiveResolver,
parentRecordID: DataID,
): mixed {
const {fragment} = field;
// Found when reading the resolver fragment, which can happen either when
// evaluating the resolver and it calls readFragment, or when checking if the
// inputs have changed since a previous evaluation:
let snapshot: ?Snapshot;
// The function `getDataForResolverFragment` serves two purposes:
// 1. To memoize reads of the resolver's root fragment. This is important
// because we may read it twice. Once to check if the results have changed
// since last read, and once when we actually evaluate.
// 2. To intercept the snapshot so that it can be cached by the resolver
// cache. This is what enables the change detection described in #1.
//
// Note: In the future this can be moved into the ResolverCache.
const getDataForResolverFragment = (
singularReaderSelector: SingularReaderSelector,
) => {
if (snapshot != null) {
// It was already read when checking for input staleness; no need to read it again.
// Note that the variables like fragmentSeenRecordIDs in the outer closure will have
// already been set and will still be used in this case.
return {
data: snapshot.data,
isMissingData: snapshot.isMissingData,
errorResponseFields: snapshot.errorResponseFields,
};
}
snapshot = read(
this._recordSource,
singularReaderSelector,
this._resolverCache,
);
return {
data: snapshot.data,
isMissingData: snapshot.isMissingData,
errorResponseFields: snapshot.errorResponseFields,
};
};
// This function `evaluate` tells the resolver cache how to read this
// resolver. It returns an `EvaluationResult` which gives the resolver cache:
// * `resolverResult` The value returned by the resolver function
// * `snapshot` The snapshot returned when reading the resolver's root fragment (if it has one)
// * `error` If the resolver throws, its error is caught (inside
// `getResolverValue`) and converted into an error object.
const evaluate = (): EvaluationResult<mixed> => {
if (fragment != null) {
const key = {
__id: parentRecordID,
__fragmentOwner: this._owner,
__fragments: {
[fragment.name]: fragment.args
? getArgumentValues(fragment.args, this._variables)
: {},
},
};
const resolverContext = {getDataForResolverFragment};
return withResolverContext(resolverContext, () => {
const [resolverResult, resolverError] = getResolverValue(
field,
this._variables,
key,
this._resolverContext,
);
return {resolverResult, snapshot, error: resolverError};
});
} else {
const [resolverResult, resolverError] = getResolverValue(
field,
this._variables,
null,
this._resolverContext,
);
return {resolverResult, snapshot: undefined, error: resolverError};
}
};
const [
result,
seenRecord,
resolverError,
cachedSnapshot,
suspenseID,
updatedDataIDs,
] = this._resolverCache.readFromCacheOrEvaluate(
parentRecordID,
field,
this._variables,
evaluate,
getDataForResolverFragment,
);
this._propagateResolverMetadata(
field.path,
cachedSnapshot,
resolverError,
seenRecord,
suspenseID,
updatedDataIDs,
);
return result;
}
// Reading a resolver field can uncover missing data, errors, suspense,
// additional seen records and updated dataIDs. All of these facts must be
// represented in the snapshot we return for this fragment.
_propagateResolverMetadata(
fieldPath: string,
cachedSnapshot: ?Snapshot,
resolverError: ?Error,
seenRecord: ?DataID,
suspenseID: ?DataID,
updatedDataIDs: ?DataIDSet,
) {
// The resolver's root fragment (if there is one) may be missing data, have
// errors, or be in a suspended state. Here we propagate those cases
// upwards to mimic the behavior of having traversed into that fragment directly.
if (cachedSnapshot != null) {
if (cachedSnapshot.missingClientEdges != null) {
for (const missing of cachedSnapshot.missingClientEdges) {
this._missingClientEdges.push(missing);
}
}
if (cachedSnapshot.missingLiveResolverFields != null) {
this._isMissingData =
this._isMissingData ||
cachedSnapshot.missingLiveResolverFields.length > 0;
for (const missingResolverField of cachedSnapshot.missingLiveResolverFields) {
this._missingLiveResolverFields.push(missingResolverField);
}
}
if (cachedSnapshot.errorResponseFields != null) {
if (this._errorResponseFields == null) {
this._errorResponseFields = [];
}
for (const error of cachedSnapshot.errorResponseFields) {
if (this._selector.node.metadata?.throwOnFieldError === true) {
// If this fragment is @throwOnFieldError, any destructive error
// encountered inside a resolver's fragment is equivilent to the
// resolver field having a field error, and we want that to cause this
// fragment to throw. So, we propagate all errors as is.
this._errorResponseFields.push(error);
} else {
// If this fragment is _not_ @throwOnFieldError, we will simply
// accept that any destructive errors encountered in the resolver's
// root fragment will cause the resolver to return null, and well
// pass the errors along to the logger marked as "handled".
this._errorResponseFields.push(markFieldErrorHasHandled(error));
}
}
}
this._isMissingData = this._isMissingData || cachedSnapshot.isMissingData;
}
// If the resolver errored, we track that as part of our traversal so that
// the errors can be attached to this read's snapshot. This allows the error
// to be logged.
if (resolverError) {
const errorEvent = {
kind: 'relay_resolver.error',
fieldPath,
owner: this._fragmentName,
error: resolverError,
shouldThrow: this._selector.node.metadata?.throwOnFieldError ?? false,
handled: false,
};
if (this._errorResponseFields == null) {
this._errorResponseFields = [errorEvent];
} else {
this._errorResponseFields.push(errorEvent);
}
}
// The resolver itself creates a record in the store. We record that we've
// read this record so that subscribers to this snapshot also subscribe to
// this resolver.
if (seenRecord != null) {
this._seenRecords.add(seenRecord);
}
// If this resolver, or a dependency of this resolver, has suspended, we
// need to report that in our snapshot. The `suspenseID` is the key in to
// store where the suspended LiveState value lives. This ID allows readers
// of the snapshot to subscribe to updates on that live resolver so that
// they know when to unsuspend.
if (suspenseID != null) {
this._isMissingData = true;
this._missingLiveResolverFields.push(suspenseID);
}
if (updatedDataIDs != null) {
for (const recordID of updatedDataIDs) {
this._updatedDataIDs.add(recordID);
}
}
}
_readClientEdge(
field: ReaderClientEdge,
record: Record,
data: SelectorData,
): ?mixed {
const backingField = field.backingField;
// Because ReaderClientExtension doesn't have `alias` or `name` and so I don't know
// how to get its fieldName or storageKey yet:
invariant(
backingField.kind !== 'ClientExtension',
'Client extension client edges are not yet implemented.',
);
const fieldName = backingField.alias ?? backingField.name;
const backingFieldData = {};
this._traverseSelections([backingField], record, backingFieldData);
// At this point, backingFieldData is an object with a single key (fieldName)
// whose value is the value returned from the resolver, or a suspense sentinel.
// $FlowFixMe[invalid-computed-prop]
const clientEdgeResolverResponse = backingFieldData[fieldName];
if (
clientEdgeResolverResponse == null ||
isSuspenseSentinel(clientEdgeResolverResponse)
) {
data[fieldName] = clientEdgeResolverResponse;
return clientEdgeResolverResponse;
}
if (field.linkedField.plural) {
invariant(
Array.isArray(clientEdgeResolverResponse),
'Expected plural Client Edge Relay Resolver at `%s` in `%s` to return an array containing IDs or objects with shape {id}.',
backingField.path,
this._owner.identifier,
);
let storeIDs: $ReadOnlyArray<DataID>;
invariant(
field.kind === 'ClientEdgeToClientObject',
'Unexpected Client Edge to plural server type `%s`. This should be prevented by the compiler.',
field.kind,
);
if (field.backingField.normalizationInfo == null) {
// @edgeTo case where we need to ensure that the record has `id` field
storeIDs = clientEdgeResolverResponse.map(itemResponse => {
const concreteType = field.concreteType ?? itemResponse.__typename;
invariant(
typeof concreteType === 'string',
'Expected resolver for field at `%s` in `%s` modeling an edge to an abstract type to return an object with a `__typename` property.',
backingField.path,
this._owner.identifier,
);
const localId = extractIdFromResponse(
itemResponse,
backingField.path,
this._owner.identifier,
);
const id = this._resolverCache.ensureClientRecord(
localId,
concreteType,
);
const modelResolvers = field.modelResolvers;
if (modelResolvers != null) {
const modelResolver = modelResolvers[concreteType];
invariant(
modelResolver !== undefined,
'Invalid `__typename` returned by resolver at `%s` in `%s`. Expected one of %s but got `%s`.',
backingField.path,
this._owner.identifier,
Object.keys(modelResolvers).join(', '),
concreteType,
);
const model = this._readResolverFieldImpl(modelResolver, id);
return model != null ? id : null;
}
return id;
});
} else {
// The normalization process in LiveResolverCache should take care of generating the correct ID.
storeIDs = clientEdgeResolverResponse.map(obj =>
extractIdFromResponse(obj, backingField.path, this._owner.identifier),
);
}
this._clientEdgeTraversalPath.push(null);
const edgeValues = this._readLinkedIds(
field.linkedField,
storeIDs,
record,
data,
);
this._clientEdgeTraversalPath.pop();
data[fieldName] = edgeValues;
return edgeValues;
} else {
const id = extractIdFromResponse(
clientEdgeResolverResponse,
backingField.path,
this._owner.identifier,
);
let storeID: DataID;
const concreteType =
field.concreteType ?? clientEdgeResolverResponse.__typename;
let traversalPathSegment: ClientEdgeTraversalInfo | null;
if (field.kind === 'ClientEdgeToClientObject') {
if (field.backingField.normalizationInfo == null) {
invariant(
typeof concreteType === 'string',
'Expected resolver for field at `%s` in `%s` modeling an edge to an abstract type to return an object with a `__typename` property.',
backingField.path,
this._owner.identifier,
);
// @edgeTo case where we need to ensure that the record has `id` field
storeID = this._resolverCache.ensureClientRecord(id, concreteType);
traversalPathSegment = null;
} else {
// The normalization process in LiveResolverCache should take care of generating the correct ID.
storeID = id;
traversalPathSegment = null;
}
} else {
storeID = id;
traversalPathSegment = {
readerClientEdge: field,
clientEdgeDestinationID: id,
};
}
const modelResolvers = field.modelResolvers;
if (modelResolvers != null) {
invariant(
typeof concreteType === 'string',
'Expected resolver for field at `%s` in `%s` modeling an edge to an abstract type to return an object with a `__typename` property.',
backingField.path,
this._owner.identifier,
);
const modelResolver = modelResolvers[concreteType];
invariant(
modelResolver !== undefined,
'Invalid `__typename` returned by resolver at `%s` in `%s`. Expected one of %s but got `%s`.',
backingField.path,
this._owner.identifier,
Object.keys(modelResolvers).join(', '),
concreteType,
);
const model = this._readResolverFieldImpl(modelResolver, storeID);
if (model == null) {
// If the model resolver returns undefined, we should still return null
// to match GQL behavior.
data[fieldName] = null;
return null;
}
}
this._clientEdgeTraversalPath.push(traversalPathSegment);
const prevData = data[fieldName];
invariant(
prevData == null || typeof prevData === 'object',
'RelayReader(): Expected data for field at `%s` in `%s` on record `%s` ' +
'to be an object, got `%s`.',
backingField.path,
this._owner.identifier,
RelayModernRecord.getDataID(record),
prevData,
);
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
const edgeValue = this._traverse(
field.linkedField,
storeID,
// $FlowFixMe[incompatible-variance]
prevData,
);
this._prependPreviousErrors(prevErrors, fieldName);
this._clientEdgeTraversalPath.pop();
data[fieldName] = edgeValue;
return edgeValue;
}
}
_readScalar(
field: ReaderScalarField | ReaderRelayResolver | ReaderRelayLiveResolver,
record: Record,
data: SelectorData,
): ?mixed {
const fieldName = field.alias ?? field.name;
const storageKey = getStorageKey(field, this._variables);
const value = RelayModernRecord.getValue(record, storageKey);
if (value === null) {
this._maybeAddErrorResponseFields(record, storageKey);
} else if (value === undefined) {
this._markDataAsMissing(fieldName);
}
data[fieldName] = value;
return value;
}
_readLink(
field: ReaderLinkedField,
record: Record,
data: SelectorData,
): ?mixed {
const fieldName = field.alias ?? field.name;
const storageKey = getStorageKey(field, this._variables);
const linkedID = RelayModernRecord.getLinkedRecordID(record, storageKey);
if (linkedID == null) {
data[fieldName] = linkedID;
if (linkedID === null) {
this._maybeAddErrorResponseFields(record, storageKey);
} else if (linkedID === undefined) {
this._markDataAsMissing(fieldName);
}
return linkedID;
}
const prevData = data[fieldName];
invariant(
prevData == null || typeof prevData === 'object',
'RelayReader(): Expected data for field `%s` at `%s` on record `%s` ' +
'to be an object, got `%s`.',
fieldName,
this._owner.identifier,
RelayModernRecord.getDataID(record),
prevData,
);
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
// $FlowFixMe[incompatible-variance]
const value = this._traverse(field, linkedID, prevData);
this._prependPreviousErrors(prevErrors, fieldName);
data[fieldName] = value;
return value;
}
/**
* Adds a set of field errors to `this._errorResponseFields`, ensuring the
* `fieldPath` property of existing field errors are prefixed with the given
* `fieldNameOrIndex`.
*
* In order to make field errors maximally useful in logs/errors, we want to
* include the path to the field that caused the error. A naive approach would
* be to maintain a path property on RelayReader which we push/pop field names
* to as we traverse into fields/etc. However, this would be expensive to
* maintain, and in the common case where there are no field errors, the work
* would go unused.
*
* Instead, we take a lazy approach where as we exit the recurison into a
* field/etc we prepend any errors encountered while traversing that field
* with the field name. This is somewhat more expensive in the error case, but
* ~free in the common case where there are no errors.
*
* To achieve this, named field readers must do the following to correctly
* track error filePaths:
*
* 1. Stash the value of `this._errorResponseFields` in a local variable
* 2. Set `this._errorResponseFields` to `null`
* 3. Traverse into the field
* 4. Call this method with the stashed errors and the field's name
*
* Similarly, when creating field errors, we simply initialize the `fieldPath`
* as the direct field name.
*
* Today we only use this apporach for `missing_expected_data` and
* `missing_required_field` errors, but we intend to broaden it to handle all
* field error paths.
*/
_prependPreviousErrors(
prevErrors: ?Array<ErrorResponseField>,
fieldNameOrIndex: string | number,
): void {
if (this._errorResponseFields != null) {
for (let i = 0; i < this._errorResponseFields.length; i++) {
const event = this._errorResponseFields[i];
if (
event.owner === this._fragmentName &&
(event.kind === 'missing_expected_data.throw' ||
event.kind === 'missing_expected_data.log' ||
event.kind === 'missing_required_field.throw' ||
event.kind === 'missing_required_field.log')
) {
event.fieldPath = `${fieldNameOrIndex}.${event.fieldPath}`;
}
}
if (prevErrors != null) {
for (let i = this._errorResponseFields.length - 1; i >= 0; i--) {
prevErrors.push(this._errorResponseFields[i]);
}
this._errorResponseFields = prevErrors;
}
} else {
this._errorResponseFields = prevErrors;
}
}
_readActorChange(
field: ReaderActorChange,
record: Record,
data: SelectorData,
): ?mixed {
const fieldName = field.alias ?? field.name;
const storageKey = getStorageKey(field, this._variables);
const externalRef = RelayModernRecord.getActorLinkedRecordID(
record,
storageKey,
);
if (externalRef == null) {
data[fieldName] = externalRef;
if (externalRef === undefined) {
this._markDataAsMissing(fieldName);
} else if (externalRef === null) {
this._maybeAddErrorResponseFields(record, storageKey);
}
return data[fieldName];
}
const [actorIdentifier, dataID] = externalRef;
const fragmentRef = {};
this._createFragmentPointer(
field.fragmentSpread,
RelayModernRecord.fromObject<>({
__id: dataID,
}),
fragmentRef,
);
data[fieldName] = {
__fragmentRef: fragmentRef,
__viewer: actorIdentifier,
};
return data[fieldName];
}
_readPluralLink(
field: ReaderLinkedField,
record: Record,
data: SelectorData,
): ?mixed {
const storageKey = getStorageKey(field, this._variables);
const linkedIDs = RelayModernRecord.getLinkedRecordIDs(record, storageKey);
if (linkedIDs === null) {
this._maybeAddErrorResponseFields(record, storageKey);
}
return this._readLinkedIds(field, linkedIDs, record, data);
}
_readLinkedIds(
field: ReaderLinkedField,
linkedIDs: ?$ReadOnlyArray<?DataID>,
record: Record,
data: SelectorData,
): ?mixed {
const fieldName = field.alias ?? field.name;
if (linkedIDs == null) {
data[fieldName] = linkedIDs;
if (linkedIDs === undefined) {
this._markDataAsMissing(fieldName);
}
return linkedIDs;
}
const prevData = data[fieldName];
invariant(
prevData == null || Array.isArray(prevData),
'RelayReader(): Expected data for field `%s` on record `%s` ' +
'to be an array, got `%s`.',
fieldName,
RelayModernRecord.getDataID(record),
prevData,
);
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
const linkedArray = prevData || [];
linkedIDs.forEach((linkedID, nextIndex) => {
if (linkedID == null) {
if (linkedID === undefined) {
this._markDataAsMissing(String(nextIndex));
}
// $FlowFixMe[cannot-write]
linkedArray[nextIndex] = linkedID;
return;
}
const prevItem = linkedArray[nextIndex];
invariant(
prevItem == null || typeof prevItem === 'object',
'RelayReader(): Expected data for field `%s` on record `%s` ' +
'to be an object, got `%s`.',
fieldName,
RelayModernRecord.getDataID(record),
prevItem,
);
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
// $FlowFixMe[cannot-write]
// $FlowFixMe[incompatible-variance]
linkedArray[nextIndex] = this._traverse(field, linkedID, prevItem);
this._prependPreviousErrors(prevErrors, nextIndex);
});
this._prependPreviousErrors(prevErrors, fieldName);
data[fieldName] = linkedArray;
return linkedArray;
}
/**
* Reads a ReaderModuleImport, which was generated from using the @module
* directive.
*/
_readModuleImport(
moduleImport: ReaderModuleImport,
record: Record,
data: SelectorData,
): void {
// Determine the component module from the store: if the field is missing
// it means we don't know what component to render the match with.
const componentKey = getModuleComponentKey(moduleImport.documentName);
// componentModuleProvider is used by Client 3D for read time resolvers.
const component =
moduleImport.componentModuleProvider ??
RelayModernRecord.getValue(record, componentKey);
if (component == null) {
if (component === undefined) {
this._markDataAsMissing('<module-import>');
}
return;
}
// Otherwise, read the fragment and module associated to the concrete
// type, and put that data with the result:
// - For the matched fragment, create the relevant fragment pointer and add
// the expected fragmentPropName
// - For the matched module, create a reference to the module
this._createFragmentPointer(
{
kind: 'FragmentSpread',
name: moduleImport.fragmentName,
args: moduleImport.args,
},
record,
data,
);
data[FRAGMENT_PROP_NAME_KEY] = moduleImport.fragmentPropName;
data[MODULE_COMPONENT_KEY] = component;
}
/**
* Aliased inline fragments allow the user to check if the data in an inline
* fragment was fetched. Data in the inline fragment can be conditional in the
* case of a type condition on the inline fragment or directives like `@skip`
* or `@include`.
*
* We model aliased inline fragments as a special reader node wrapped around a
* regular inline fragment reader node.
*
* This allows us to read the inline fragment as normal, check if it matched,
* and then define the alias to either contain the inline fragment's data, or
* null.
*/
_readAliasedInlineFragment(
aliasedInlineFragment: ReaderAliasedInlineFragmentSpread,
record: Record,
data: SelectorData,
) {
const prevErrors = this._errorResponseFields;
this._errorResponseFields = null;
let fieldValue = this._readInlineFragment(
aliasedInlineFragment.fragment,
record,
{},
true,
);
this._prependPreviousErrors(prevErrors, aliasedInlineFragment.name);
if (fieldValue === false) {
fieldValue = null;
}
data[aliasedInlineFragment.name] = fieldValue;
}
// Has three possible return values:
// * null: The type condition did not match
// * undefined: We are missing data
// * false: The selection contained missing @required fields
// * data: The successfully populated SelectorData object
//
// The `skipUnmatchedAbstractTypes` flag is used to signal if we should skip
// reading the contents of an inline fragment on an abstract type if we _know_
// the type condition does not match.
_readInlineFragment(
inlineFragment: ReaderInlineFragment,
record: Record,
data: SelectorData,
skipUnmatchedAbstractTypes: boolean,
): ?(SelectorData | false) {
if (inlineFragment.type == null) {
// Inline fragment without a type condition: always read data
// Usually this would get compiled away, but fragments with @alias
// and no type condition will get preserved.
const hasExpectedData = this._traverseSelections(
inlineFragment.selections,
record,
data,
);
if (hasExpectedData === false) {
return false;
}
return data;
}
const {abstractKey} = inlineFragment;
if (abstractKey == null) {
// concrete type refinement: only read data if the type exactly matches
if (!this._recordMatchesTypeCondition(record, inlineFragment.type)) {
// This selection does not match the fragment spread. Do nothing.
return null;
} else {
const hasExpectedData = this._traverseSelections(
inlineFragment.selections,
record,
data,
);
if (!hasExpectedData) {
// Bubble up null due to a missing @required field
return false;
}
}
} else {
const implementsInterface = this._implementsInterface(
record,
abstractKey,
);
if (implementsInterface === false && skipUnmatchedAbstractTypes) {
return null;
}
// store flags to reset after reading
const parentIsMissingData = this._isMissingData;
const parentIsWithinUnmatchedTypeRefinement =
this._isWithinUnmatchedTypeRefinement;
this._isWithinUnmatchedTypeRefinement =
parentIsWithinUnmatchedTypeRefinement || implementsInterface === false;
// @required is allowed within inline fragments on abstract types if they
// have @alias. So we must bubble up null if we have a missing @required
// field.
const hasMissingData = this._traverseSelections(
inlineFragment.selections,
record,
data,
);
// Reset
this._isWithinUnmatchedTypeRefinement =
parentIsWithi