packages/relay-runtime/store/DataChecker.js (565 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 strict-local * @format * @emails oncall+relay */ // flowlint ambiguous-object-type:error 'use strict'; import type {ActorIdentifier} from '../multi-actor-environment/ActorIdentifier'; import type { NormalizationField, NormalizationFlightField, NormalizationLinkedField, NormalizationModuleImport, NormalizationNode, NormalizationScalarField, NormalizationSelection, } from '../util/NormalizationNode'; import type {DataID, Variables} from '../util/RelayRuntimeTypes'; import type {GetDataID} from './RelayResponseNormalizer'; import type { MissingFieldHandler, MutableRecordSource, NormalizationSelector, OperationLoader, ReactFlightReachableExecutableDefinitions, Record, RecordSource, } from './RelayStoreTypes'; const RelayRecordSourceMutator = require('../mutations/RelayRecordSourceMutator'); const RelayRecordSourceProxy = require('../mutations/RelayRecordSourceProxy'); const getOperation = require('../util/getOperation'); const RelayConcreteNode = require('../util/RelayConcreteNode'); const RelayFeatureFlags = require('../util/RelayFeatureFlags'); const {isClientID} = require('./ClientID'); const cloneRelayHandleSourceField = require('./cloneRelayHandleSourceField'); const cloneRelayScalarHandleSourceField = require('./cloneRelayScalarHandleSourceField'); const {getLocalVariables} = require('./RelayConcreteVariables'); const RelayModernRecord = require('./RelayModernRecord'); const {EXISTENT, UNKNOWN} = require('./RelayRecordState'); const RelayStoreReactFlightUtils = require('./RelayStoreReactFlightUtils'); const RelayStoreUtils = require('./RelayStoreUtils'); const {generateTypeID} = require('./TypeID'); const invariant = require('invariant'); export type Availability = {| +status: 'available' | 'missing', +mostRecentlyInvalidatedAt: ?number, |}; const { ACTOR_CHANGE, CONDITION, CLIENT_COMPONENT, CLIENT_EXTENSION, DEFER, FLIGHT_FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT, LINKED_FIELD, LINKED_HANDLE, MODULE_IMPORT, SCALAR_FIELD, SCALAR_HANDLE, STREAM, TYPE_DISCRIMINATOR, } = RelayConcreteNode; const {ROOT_ID, getModuleOperationKey, getStorageKey, getArgumentValues} = RelayStoreUtils; /** * Synchronously check whether the records required to fulfill the given * `selector` are present in `source`. * * If a field is missing, it uses the provided handlers to attempt to substitute * data. The `target` will store all records that are modified because of a * successful substitution. * * If all records are present, returns `true`, otherwise `false`. */ function check( getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource, getTargetForActor: (actorIdentifier: ActorIdentifier) => MutableRecordSource, defaultActorIdentifier: ActorIdentifier, selector: NormalizationSelector, handlers: $ReadOnlyArray<MissingFieldHandler>, operationLoader: ?OperationLoader, getDataID: GetDataID, shouldProcessClientComponents: ?boolean, ): Availability { const {dataID, node, variables} = selector; const checker = new DataChecker( getSourceForActor, getTargetForActor, defaultActorIdentifier, variables, handlers, operationLoader, getDataID, shouldProcessClientComponents, ); return checker.check(node, dataID); } /** * @private */ class DataChecker { _handlers: $ReadOnlyArray<MissingFieldHandler>; _mostRecentlyInvalidatedAt: number | null; _mutator: RelayRecordSourceMutator; _operationLoader: OperationLoader | null; _operationLastWrittenAt: ?number; _recordSourceProxy: RelayRecordSourceProxy; _recordWasMissing: boolean; _source: RecordSource; _variables: Variables; _shouldProcessClientComponents: ?boolean; +_getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource; +_getTargetForActor: ( actorIdentifier: ActorIdentifier, ) => MutableRecordSource; +_getDataID: GetDataID; +_mutatorRecordSourceProxyCache: Map< ActorIdentifier, [RelayRecordSourceMutator, RelayRecordSourceProxy], >; constructor( getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource, getTargetForActor: ( actorIdentifier: ActorIdentifier, ) => MutableRecordSource, defaultActorIdentifier: ActorIdentifier, variables: Variables, handlers: $ReadOnlyArray<MissingFieldHandler>, operationLoader: ?OperationLoader, getDataID: GetDataID, shouldProcessClientComponents: ?boolean, ) { this._getSourceForActor = getSourceForActor; this._getTargetForActor = getTargetForActor; this._getDataID = getDataID; this._source = getSourceForActor(defaultActorIdentifier); this._mutatorRecordSourceProxyCache = new Map(); const [mutator, recordSourceProxy] = this._getMutatorAndRecordProxyForActor( defaultActorIdentifier, ); this._mostRecentlyInvalidatedAt = null; this._handlers = handlers; this._mutator = mutator; this._operationLoader = operationLoader ?? null; this._recordSourceProxy = recordSourceProxy; this._recordWasMissing = false; this._variables = variables; this._shouldProcessClientComponents = shouldProcessClientComponents; } _getMutatorAndRecordProxyForActor( actorIdentifier: ActorIdentifier, ): [RelayRecordSourceMutator, RelayRecordSourceProxy] { let tuple = this._mutatorRecordSourceProxyCache.get(actorIdentifier); if (tuple == null) { const target = this._getTargetForActor(actorIdentifier); const mutator = new RelayRecordSourceMutator( this._getSourceForActor(actorIdentifier), target, ); const recordSourceProxy = new RelayRecordSourceProxy( mutator, this._getDataID, ); tuple = [mutator, recordSourceProxy]; this._mutatorRecordSourceProxyCache.set(actorIdentifier, tuple); } return tuple; } check(node: NormalizationNode, dataID: DataID): Availability { this._traverse(node, dataID); return this._recordWasMissing === true ? { status: 'missing', mostRecentlyInvalidatedAt: this._mostRecentlyInvalidatedAt, } : { status: 'available', mostRecentlyInvalidatedAt: this._mostRecentlyInvalidatedAt, }; } _getVariableValue(name: string): mixed { invariant( this._variables.hasOwnProperty(name), 'RelayAsyncLoader(): Undefined variable `%s`.', name, ); return this._variables[name]; } _handleMissing(): void { this._recordWasMissing = true; } _getDataForHandlers( field: NormalizationField, dataID: DataID, ): { args: Variables, record: ?Record, ... } { return { /* $FlowFixMe[class-object-subtyping] added when improving typing for * this parameters */ args: field.args ? getArgumentValues(field.args, this._variables) : {}, // Getting a snapshot of the record state is potentially expensive since // we will need to merge the sink and source records. Since we do not create // any new records in this process, it is probably reasonable to provide // handlers with a copy of the source record. // The only thing that the provided record will not contain is fields // added by previous handlers. record: this._source.get(dataID), }; } _handleMissingScalarField( field: NormalizationScalarField, dataID: DataID, ): mixed { if (field.name === 'id' && field.alias == null && isClientID(dataID)) { return undefined; } const {args, record} = this._getDataForHandlers(field, dataID); for (const handler of this._handlers) { if (handler.kind === 'scalar') { const newValue = handler.handle( field, record, args, this._recordSourceProxy, ); if (newValue !== undefined) { return newValue; } } } this._handleMissing(); } _handleMissingLinkField( field: NormalizationLinkedField, dataID: DataID, ): ?DataID { const {args, record} = this._getDataForHandlers(field, dataID); for (const handler of this._handlers) { if (handler.kind === 'linked') { const newValue = handler.handle( field, record, args, this._recordSourceProxy, ); if ( newValue !== undefined && (newValue === null || this._mutator.getStatus(newValue) === EXISTENT) ) { return newValue; } } } this._handleMissing(); } _handleMissingPluralLinkField( field: NormalizationLinkedField, dataID: DataID, ): ?Array<?DataID> { const {args, record} = this._getDataForHandlers(field, dataID); for (const handler of this._handlers) { if (handler.kind === 'pluralLinked') { const newValue = handler.handle( field, record, args, this._recordSourceProxy, ); if (newValue != null) { const allItemsKnown = newValue.every( linkedID => linkedID != null && this._mutator.getStatus(linkedID) === EXISTENT, ); if (allItemsKnown) { return newValue; } } else if (newValue === null) { return null; } } } this._handleMissing(); } _traverse(node: NormalizationNode, dataID: DataID): void { const status = this._mutator.getStatus(dataID); if (status === UNKNOWN) { this._handleMissing(); } if (status === EXISTENT) { const record = this._source.get(dataID); const invalidatedAt = RelayModernRecord.getInvalidationEpoch(record); if (invalidatedAt != null) { this._mostRecentlyInvalidatedAt = this._mostRecentlyInvalidatedAt != null ? Math.max(this._mostRecentlyInvalidatedAt, invalidatedAt) : invalidatedAt; } this._traverseSelections(node.selections, dataID); } } _traverseSelections( selections: $ReadOnlyArray<NormalizationSelection>, dataID: DataID, ): void { selections.forEach(selection => { switch (selection.kind) { case SCALAR_FIELD: this._checkScalar(selection, dataID); break; case LINKED_FIELD: if (selection.plural) { this._checkPluralLink(selection, dataID); } else { this._checkLink(selection, dataID); } break; case ACTOR_CHANGE: this._checkActorChange(selection.linkedField, dataID); break; case CONDITION: const conditionValue = Boolean( this._getVariableValue(selection.condition), ); if (conditionValue === selection.passingValue) { this._traverseSelections(selection.selections, dataID); } break; case INLINE_FRAGMENT: { const {abstractKey} = selection; if (abstractKey == null) { // concrete type refinement: only check data if the type exactly matches const typeName = this._mutator.getType(dataID); if (typeName === selection.type) { this._traverseSelections(selection.selections, dataID); } } else { // Abstract refinement: check data depending on whether the type // conforms to the interface/union or not: // - Type known to _not_ implement the interface: don't check the selections. // - Type is known _to_ implement the interface: check selections. // - Unknown whether the type implements the interface: don't check the selections // and treat the data as missing; we do this because the Relay Compiler // guarantees that the type discriminator will always be fetched. const recordType = this._mutator.getType(dataID); invariant( recordType != null, 'DataChecker: Expected record `%s` to have a known type', dataID, ); const typeID = generateTypeID(recordType); const implementsInterface = this._mutator.getValue( typeID, abstractKey, ); if (implementsInterface === true) { this._traverseSelections(selection.selections, dataID); } else if (implementsInterface == null) { // unsure if the type implements the interface: data is // missing so don't bother reading the fragment this._handleMissing(); } // else false: known to not implement the interface } break; } case LINKED_HANDLE: { // Handles have no selections themselves; traverse the original field // where the handle was set-up instead. const handleField = cloneRelayHandleSourceField( selection, selections, this._variables, ); if (handleField.plural) { this._checkPluralLink(handleField, dataID); } else { this._checkLink(handleField, dataID); } break; } case SCALAR_HANDLE: { const handleField = cloneRelayScalarHandleSourceField( selection, selections, this._variables, ); this._checkScalar(handleField, dataID); break; } case MODULE_IMPORT: this._checkModuleImport(selection, dataID); break; case DEFER: case STREAM: this._traverseSelections(selection.selections, dataID); break; case FRAGMENT_SPREAD: const prevVariables = this._variables; this._variables = getLocalVariables( this._variables, selection.fragment.argumentDefinitions, selection.args, ); this._traverseSelections(selection.fragment.selections, dataID); this._variables = prevVariables; break; case CLIENT_EXTENSION: const recordWasMissing = this._recordWasMissing; this._traverseSelections(selection.selections, dataID); this._recordWasMissing = recordWasMissing; break; case TYPE_DISCRIMINATOR: const {abstractKey} = selection; const recordType = this._mutator.getType(dataID); invariant( recordType != null, 'DataChecker: Expected record `%s` to have a known type', dataID, ); const typeID = generateTypeID(recordType); const implementsInterface = this._mutator.getValue( typeID, abstractKey, ); if (implementsInterface == null) { // unsure if the type implements the interface: data is // missing this._handleMissing(); } // else: if it does or doesn't implement, we don't need to check or skip anything else break; case FLIGHT_FIELD: if (RelayFeatureFlags.ENABLE_REACT_FLIGHT_COMPONENT_FIELD) { this._checkFlightField(selection, dataID); } else { throw new Error('Flight fields are not yet supported.'); } break; case CLIENT_COMPONENT: if (this._shouldProcessClientComponents === false) { break; } this._traverseSelections(selection.fragment.selections, dataID); break; default: (selection: empty); invariant( false, 'RelayAsyncLoader(): Unexpected ast kind `%s`.', selection.kind, ); } }); } _checkModuleImport( moduleImport: NormalizationModuleImport, dataID: DataID, ): void { const operationLoader = this._operationLoader; invariant( operationLoader !== null, 'DataChecker: Expected an operationLoader to be configured when using `@module`.', ); const operationKey = getModuleOperationKey(moduleImport.documentName); const operationReference = this._mutator.getValue(dataID, operationKey); if (operationReference == null) { if (operationReference === undefined) { this._handleMissing(); } return; } const normalizationRootNode = operationLoader.get(operationReference); if (normalizationRootNode != null) { const operation = getOperation(normalizationRootNode); const prevVariables = this._variables; this._variables = getLocalVariables( this._variables, operation.argumentDefinitions, moduleImport.args, ); this._traverse(operation, dataID); this._variables = prevVariables; } else { // If the fragment is not available, we assume that the data cannot have been // processed yet and must therefore be missing. this._handleMissing(); } } _checkScalar(field: NormalizationScalarField, dataID: DataID): void { const storageKey = getStorageKey(field, this._variables); let fieldValue = this._mutator.getValue(dataID, storageKey); if (fieldValue === undefined) { fieldValue = this._handleMissingScalarField(field, dataID); if (fieldValue !== undefined) { this._mutator.setValue(dataID, storageKey, fieldValue); } } } _checkLink(field: NormalizationLinkedField, dataID: DataID): void { const storageKey = getStorageKey(field, this._variables); let linkedID = this._mutator.getLinkedRecordID(dataID, storageKey); if (linkedID === undefined) { linkedID = this._handleMissingLinkField(field, dataID); if (linkedID != null) { this._mutator.setLinkedRecordID(dataID, storageKey, linkedID); } else if (linkedID === null) { this._mutator.setValue(dataID, storageKey, null); } } if (linkedID != null) { this._traverse(field, linkedID); } } _checkPluralLink(field: NormalizationLinkedField, dataID: DataID): void { const storageKey = getStorageKey(field, this._variables); let linkedIDs = this._mutator.getLinkedRecordIDs(dataID, storageKey); if (linkedIDs === undefined) { linkedIDs = this._handleMissingPluralLinkField(field, dataID); if (linkedIDs != null) { this._mutator.setLinkedRecordIDs(dataID, storageKey, linkedIDs); } else if (linkedIDs === null) { this._mutator.setValue(dataID, storageKey, null); } } if (linkedIDs) { linkedIDs.forEach(linkedID => { if (linkedID != null) { this._traverse(field, linkedID); } }); } } _checkActorChange(field: NormalizationLinkedField, dataID: DataID): void { const storageKey = getStorageKey(field, this._variables); const record = this._source.get(dataID); const tuple = record != null ? RelayModernRecord.getActorLinkedRecordID(record, storageKey) : record; if (tuple == null) { if (tuple === undefined) { this._handleMissing(); } } else { const [actorIdentifier, linkedID] = tuple; const prevSource = this._source; const prevMutator = this._mutator; const prevRecordSourceProxy = this._recordSourceProxy; const [mutator, recordSourceProxy] = this._getMutatorAndRecordProxyForActor(actorIdentifier); this._source = this._getSourceForActor(actorIdentifier); this._mutator = mutator; this._recordSourceProxy = recordSourceProxy; this._traverse(field, linkedID); this._source = prevSource; this._mutator = prevMutator; this._recordSourceProxy = prevRecordSourceProxy; } } _checkFlightField(field: NormalizationFlightField, dataID: DataID): void { const storageKey = getStorageKey(field, this._variables); const linkedID = this._mutator.getLinkedRecordID(dataID, storageKey); if (linkedID == null) { if (linkedID === undefined) { this._handleMissing(); return; } return; } const tree = this._mutator.getValue( linkedID, RelayStoreReactFlightUtils.REACT_FLIGHT_TREE_STORAGE_KEY, ); const reachableExecutableDefinitions = this._mutator.getValue( linkedID, RelayStoreReactFlightUtils.REACT_FLIGHT_EXECUTABLE_DEFINITIONS_STORAGE_KEY, ); if (tree == null || !Array.isArray(reachableExecutableDefinitions)) { this._handleMissing(); return; } const operationLoader = this._operationLoader; invariant( operationLoader !== null, 'DataChecker: Expected an operationLoader to be configured when using ' + 'React Flight.', ); // In Flight, the variables that are in scope for reachable executable // definitions aren't the same as what's in scope for the outer query. const prevVariables = this._variables; // $FlowFixMe[incompatible-cast] for (const definition of (reachableExecutableDefinitions: Array<ReactFlightReachableExecutableDefinitions>)) { this._variables = definition.variables; const normalizationRootNode = operationLoader.get(definition.module); if (normalizationRootNode != null) { const operation = getOperation(normalizationRootNode); this._traverseSelections(operation.selections, ROOT_ID); } else { // If the fragment is not available, we assume that the data cannot have // been processed yet and must therefore be missing. this._handleMissing(); } } this._variables = prevVariables; } } module.exports = { check, };