relay-runtime
Version:
A core runtime for building GraphQL-driven applications.
783 lines (709 loc) • 24.4 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 {GraphQLResponseWithData} from '../network/RelayNetworkTypes';
import type {
NormalizationLinkedField,
NormalizationOperation,
NormalizationRootNode,
} from '../util/NormalizationNode';
import type {Variables} from '../util/RelayRuntimeTypes';
import type {NormalizationOptions} from './RelayResponseNormalizer';
import type {
DeferPlaceholder,
FollowupPayload,
HandleFieldPayload,
IncrementalDataPlaceholder,
ModuleImportPayload,
MutableRecordSource,
NormalizationSelector,
NormalizeResponseFunction,
OperationLoader,
Record,
RecordSourceProxy,
RelayResponsePayload,
StreamPlaceholder,
} from './RelayStoreTypes';
const {stableCopy} = require('../util/stableCopy');
const {generateClientID} = require('./ClientID');
const defaultGetDataID = require('./defaultGetDataID');
const RelayModernRecord = require('./RelayModernRecord');
const {createNormalizationSelector} = require('./RelayModernSelector');
const {ROOT_ID, ROOT_TYPE, getStorageKey} = require('./RelayStoreUtils');
function err(message: string): Error {
const e = new Error(message);
void e.stack;
return e;
}
function stableStringify(value: unknown): string {
return JSON.stringify(stableCopy(value)) ?? '';
}
export type NormalizationResult = Readonly<{
payloads: ReadonlyArray<RelayResponsePayload>,
// Each pending module resolves to its own NormalizationResult so the caller
// can recursively drain nested incremental data (extra payloads + further
// pending modules) produced by an async @module load. Mirrors the recursion
// that OperationExecutor performs synchronously via `_processPayloadFollowups`.
pendingModules: ReadonlyArray<Promise<NormalizationResult>>,
}>;
type Config = Readonly<{
getDataID?: (
fieldValue: {readonly [string]: unknown},
typeName: string,
) => unknown,
normalizeResponse: NormalizeResponseFunction,
operation: NormalizationOperation,
operationLoader?: ?OperationLoader,
treatMissingFieldsAsNull?: boolean,
variables: Variables,
}>;
type ParentEntry = {
fieldPayloads: Array<HandleFieldPayload>,
record: Record,
};
/**
* Per-request normalization engine. Normalizes raw server responses and
* returns pre-normalized RelayResponsePayload objects (with isPreNormalized:
* true) that OperationExecutor can commit directly to the store, bypassing
* the normalization pass it would otherwise perform.
*
* Handles initial responses, @defer chunks, @stream items, and @module
* followups. Tracks per-request state for incremental delivery including
* placeholder registration, response buffering, and parent record caching.
*
* Returns results synchronously via arrays. Async @module loads are returned
* as Promises — the caller bridges them to the sink.
*/
class NormalizationEngine {
_normalizeResponse: NormalizeResponseFunction;
_operationLoader: ?OperationLoader;
_options: NormalizationOptions;
_rootSelector: NormalizationSelector;
_useExecTimeResolvers: boolean;
// Per-request incremental delivery state
_placeholders: Map<string, IncrementalDataPlaceholder>;
_bufferedResponses: Map<string, Array<GraphQLResponseWithData>>;
_parentRecords: Map<string, ParentEntry>;
_serverComplete: boolean;
constructor(config: Config) {
this._normalizeResponse = config.normalizeResponse;
this._operationLoader = config.operationLoader ?? null;
this._rootSelector = {
dataID: ROOT_ID,
node: config.operation,
variables: config.variables,
};
this._options = {
deferDeduplicatedFields: false,
getDataID: config.getDataID ?? defaultGetDataID,
log: null,
path: [],
treatMissingFieldsAsNull: config.treatMissingFieldsAsNull ?? false,
};
this._useExecTimeResolvers =
config.operation.use_exec_time_resolvers ??
config.operation.exec_time_resolvers_enabled_provider?.get() === true ??
false;
this._placeholders = new Map();
this._bufferedResponses = new Map();
this._parentRecords = new Map();
this._serverComplete = false;
}
/**
* Process an initial (non-incremental) server response. Normalizes the
* response, registers any incremental placeholders, processes @module
* followups, and flushes buffered responses.
*
* Returns an array of payloads (the primary result plus any flushed
* buffered responses) and an array of Promises for async @module loads.
*/
processResponse(response: GraphQLResponseWithData): NormalizationResult {
const payload = this._normalizeResponse(
response,
this._rootSelector,
ROOT_TYPE,
this._options,
this._useExecTimeResolvers,
);
const extraPayloads: Array<RelayResponsePayload> = [];
const pendingModules: Array<Promise<NormalizationResult>> = [];
// Register incremental placeholders and flush any buffered responses
if (
payload.incrementalPlaceholders != null &&
payload.incrementalPlaceholders.length > 0
) {
this._registerPlaceholders(
payload.incrementalPlaceholders,
payload.source,
payload.fieldPayloads,
extraPayloads,
pendingModules,
);
}
// Process module followups
if (
payload.followupPayloads != null &&
payload.followupPayloads.length > 0
) {
this._processFollowups(
payload.followupPayloads,
extraPayloads,
pendingModules,
);
}
const primaryPayload: RelayResponsePayload = {
...payload,
followupPayloads: null,
incrementalPlaceholders: null,
isFinal: this._computeIsFinal(payload.isFinal),
isPreNormalized: true,
};
return {
payloads: [primaryPayload, ...extraPayloads],
pendingModules,
};
}
/**
* Process an incremental response (@defer chunk or @stream item).
* Matches the response to a registered placeholder by label + path.
*
* Returns null if the response was buffered (no placeholder yet).
* Otherwise returns payloads array and pending module Promises.
*/
processIncrementalResponse(
response: GraphQLResponseWithData,
): ?NormalizationResult {
const label: ?string = response.label;
const path: ?ReadonlyArray<string | number> = response.path;
if (label == null || path == null) {
throw err(
'NormalizationEngine: Expected incremental response to have ' +
'`label` and `path` properties.',
);
}
const isDefer = label.indexOf('$defer$') !== -1;
const pathKey = isDefer
? path.map(String).join('.')
: path.slice(0, -2).map(String).join('.');
const key = makeKey(label, pathKey);
const placeholder = this._placeholders.get(key);
if (placeholder == null) {
// Buffer: response arrived before placeholder was registered
let buffer = this._bufferedResponses.get(key);
if (buffer == null) {
buffer = [];
this._bufferedResponses.set(key, buffer);
}
buffer.push(response);
return null;
}
if (placeholder.kind === 'defer') {
return this._processDefer(response, path, placeholder);
} else {
return this._processStream(response, path, placeholder);
}
}
/**
* Mark the server stream as complete for isFinal computation.
*/
setServerComplete(): void {
this._serverComplete = true;
}
/**
* Whether the server is complete AND all incremental data has been received.
*/
isFinal(): boolean {
return (
this._serverComplete &&
this._bufferedResponses.size === 0 &&
this._placeholders.size === 0
);
}
// ---------------------------------------------------------------------------
// Private: @defer handling
// ---------------------------------------------------------------------------
_processDefer(
response: GraphQLResponseWithData,
_path: ReadonlyArray<unknown>,
placeholder: DeferPlaceholder,
): NormalizationResult {
const payload = this._normalizeResponse(
response,
placeholder.selector,
placeholder.typeName,
{
...this._options,
deferDeduplicatedFields: true,
path: placeholder.path,
},
this._useExecTimeResolvers,
);
const extraPayloads: Array<RelayResponsePayload> = [];
const pendingModules: Array<Promise<NormalizationResult>> = [];
// Register nested placeholders (recursive @defer)
if (
payload.incrementalPlaceholders != null &&
payload.incrementalPlaceholders.length > 0
) {
this._registerPlaceholders(
payload.incrementalPlaceholders,
payload.source,
payload.fieldPayloads,
extraPayloads,
pendingModules,
);
}
// Process nested followups (recursive @module)
if (
payload.followupPayloads != null &&
payload.followupPayloads.length > 0
) {
this._processFollowups(
payload.followupPayloads,
extraPayloads,
pendingModules,
);
}
// Replay handle field payloads from parent
// TODO(skyyao): emit parent fieldPayloads as a separate payload (with empty
// source) like OperationExecutor._processDeferResponse so handles run
// against the just-committed updated parent record.
const parentID = placeholder.selector.dataID;
const parentEntry = this._parentRecords.get(parentID);
let fieldPayloads = payload.fieldPayloads;
if (parentEntry != null && parentEntry.fieldPayloads.length > 0) {
fieldPayloads = (fieldPayloads ?? []).concat(parentEntry.fieldPayloads);
}
const primaryPayload: RelayResponsePayload = {
...payload,
fieldPayloads,
followupPayloads: null,
incrementalPlaceholders: null,
isFinal: this._computeIsFinal(payload.isFinal),
isPreNormalized: true,
};
return {
payloads: [primaryPayload, ...extraPayloads],
pendingModules,
};
}
// ---------------------------------------------------------------------------
// Private: @stream handling
// ---------------------------------------------------------------------------
_processStream(
response: GraphQLResponseWithData,
path: ReadonlyArray<unknown>,
placeholder: StreamPlaceholder,
): NormalizationResult {
const {node, parentID, variables} = placeholder;
// Find the LinkedField where @stream was applied
const field = node.selections[0];
if (
field == null ||
field.kind !== 'LinkedField' ||
field.plural !== true
) {
throw err(
'NormalizationEngine: Expected @stream to be used on a plural field.',
);
}
const {
fieldPayloads,
itemID,
itemIndex,
prevIDs,
relayPayload,
storageKey,
} = this._normalizeStreamItem(
response,
parentID,
field,
variables,
path,
placeholder.path,
);
// Build store updater for concurrent modification detection
const storeUpdater = (store: RecordSourceProxy) => {
const currentParentRecord = store.get(parentID);
if (currentParentRecord == null) {
return;
}
const currentItems = currentParentRecord.getLinkedRecords(storageKey);
if (currentItems == null) {
return;
}
if (
currentItems.length !== prevIDs.length ||
currentItems.some(
(item, i) => prevIDs[i] !== (item && item.getDataID()),
)
) {
return; // Concurrent modification -- drop stale stream data
}
const nextItems = [...currentItems];
nextItems[itemIndex] = store.get(itemID);
currentParentRecord.setLinkedRecords(nextItems, storageKey);
};
// Replay handle field payloads from parent
// TODO(skyyao): emit parent fieldPayloads as a separate payload (with empty
// source) like OperationExecutor._processStreamResponse so handles run
// against the just-committed updated parent record.
let mergedFieldPayloads = relayPayload.fieldPayloads;
if (fieldPayloads.length > 0) {
mergedFieldPayloads = (mergedFieldPayloads ?? []).concat(fieldPayloads);
}
return {
payloads: [
{
...relayPayload,
fieldPayloads: mergedFieldPayloads,
followupPayloads: null,
incrementalPlaceholders: null,
isFinal: this._computeIsFinal(relayPayload.isFinal),
isPreNormalized: true,
storeUpdater,
},
],
pendingModules: [],
};
}
_normalizeStreamItem(
response: GraphQLResponseWithData,
parentID: string,
field: NormalizationLinkedField,
variables: Variables,
path: ReadonlyArray<unknown>,
normalizationPath: ReadonlyArray<string>,
): {
fieldPayloads: Array<HandleFieldPayload>,
itemID: string,
itemIndex: number,
prevIDs: Array<?string>,
relayPayload: RelayResponsePayload,
storageKey: string,
} {
const {data} = response;
if (typeof data !== 'object') {
throw err(
'NormalizationEngine: Expected the GraphQL @stream payload `data` ' +
'value to be an object.',
);
}
const responseKey = field.alias ?? field.name;
const storageKey = getStorageKey(field, variables);
const parentEntry = this._parentRecords.get(parentID);
if (parentEntry == null) {
throw err(
'NormalizationEngine: Expected the parent record `' +
parentID +
'` for @stream data to exist.',
);
}
const {fieldPayloads, record: parentRecord} = parentEntry;
const prevIDs = RelayModernRecord.getLinkedRecordIDs(
parentRecord,
storageKey,
);
if (prevIDs == null) {
throw err(
'NormalizationEngine: Expected record `' +
parentID +
'` to have fetched field `' +
field.name +
'` with @stream.',
);
}
const finalPathEntry = path[path.length - 1];
const itemIndex = parseInt(finalPathEntry, 10);
if (itemIndex !== finalPathEntry || itemIndex < 0) {
throw err(
'NormalizationEngine: Expected path for @stream to end in a ' +
'positive integer index, got `' +
String(finalPathEntry) +
'`',
);
}
const typeName = field.concreteType ?? (data as $FlowFixMe).__typename;
if (typeof typeName !== 'string') {
throw err(
'NormalizationEngine: Expected @stream field `' +
field.name +
'` to have a __typename.',
);
}
const getDataID = this._options.getDataID;
const itemID =
(typeof getDataID === 'function'
? getDataID(data as $FlowFixMe, typeName)
: null) ??
prevIDs?.[itemIndex] ??
generateClientID(parentID, storageKey, itemIndex);
if (typeof itemID !== 'string') {
throw err(
'NormalizationEngine: Expected id of elements of field `' +
storageKey +
'` to be strings.',
);
}
const selector = createNormalizationSelector(field, itemID, variables);
const nextParentRecord = RelayModernRecord.clone(parentRecord);
const nextIDs = [...prevIDs];
nextIDs[itemIndex] = itemID;
RelayModernRecord.setLinkedRecordIDs(nextParentRecord, storageKey, nextIDs);
this._parentRecords.set(parentID, {
fieldPayloads,
record: nextParentRecord,
});
const relayPayload = this._normalizeResponse(
response,
selector,
typeName,
{
...this._options,
path: [...normalizationPath, responseKey, String(itemIndex)],
},
this._useExecTimeResolvers,
);
return {
fieldPayloads,
itemID,
itemIndex,
prevIDs,
relayPayload,
storageKey,
};
}
// ---------------------------------------------------------------------------
// Private: placeholder registration
// ---------------------------------------------------------------------------
_registerPlaceholders(
placeholders: ReadonlyArray<IncrementalDataPlaceholder>,
source: MutableRecordSource,
fieldPayloads: ?ReadonlyArray<HandleFieldPayload>,
outPayloads: Array<RelayResponsePayload>,
outPendingModules: Array<Promise<NormalizationResult>>,
): void {
for (let i = 0; i < placeholders.length; i++) {
const placeholder = placeholders[i];
const {label, path} = placeholder;
const pathKey = path.map(String).join('.');
const key = makeKey(label, pathKey);
this._placeholders.set(key, placeholder);
// Cache parent record for @stream concurrent modification detection
// and for @defer handle field replay
let parentID: string;
if (placeholder.kind === 'stream') {
parentID = placeholder.parentID;
} else {
parentID = placeholder.selector.dataID;
}
const parentRecord = source.get(parentID);
if (parentRecord == null) {
throw err(
'NormalizationEngine: Expected record `' + parentID + '` to exist.',
);
}
const parentPayloads = (fieldPayloads ?? []).filter(
(fieldPayload: HandleFieldPayload) => {
const fieldID = generateClientID(
fieldPayload.dataID,
fieldPayload.fieldKey,
);
return fieldPayload.dataID === parentID || fieldID === parentID;
},
);
const previousEntry = this._parentRecords.get(parentID);
if (previousEntry != null) {
const nextRecord = RelayModernRecord.update(
previousEntry.record,
parentRecord,
);
const handlePayloads = new Map<string, HandleFieldPayload>();
// TODO(skyyao): use cheaper structural key (dataID + fieldKey + handleKey)
// to skip serializing args/handleArgs; apply the same optimization to
// OperationExecutor.js:1317.
for (let j = 0; j < previousEntry.fieldPayloads.length; j++) {
const p = previousEntry.fieldPayloads[j];
handlePayloads.set(stableStringify(p), p);
}
for (let j = 0; j < parentPayloads.length; j++) {
const p = parentPayloads[j];
handlePayloads.set(stableStringify(p), p);
}
this._parentRecords.set(parentID, {
fieldPayloads: Array.from(handlePayloads.values()),
record: nextRecord,
});
} else {
this._parentRecords.set(parentID, {
fieldPayloads: parentPayloads,
record: parentRecord,
});
}
// Flush any buffered responses that arrived before this placeholder
const buffered = this._bufferedResponses.get(key);
if (buffered != null) {
this._bufferedResponses.delete(key);
for (let j = 0; j < buffered.length; j++) {
const resp = buffered[j];
let result: ?NormalizationResult;
if (placeholder.kind === 'defer') {
result = this._processDefer(resp, resp.path ?? [], placeholder);
} else {
result = this._processStream(resp, resp.path ?? [], placeholder);
}
if (result != null) {
outPayloads.push(...result.payloads);
outPendingModules.push(...result.pendingModules);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Private: @module followup handling
// ---------------------------------------------------------------------------
_processFollowups(
followups: ReadonlyArray<FollowupPayload>,
outPayloads: Array<RelayResponsePayload>,
outPendingModules: Array<Promise<NormalizationResult>>,
): void {
for (let i = 0; i < followups.length; i++) {
const followup = followups[i];
if (followup.kind === 'ModuleImportPayload') {
this._processModuleImport(followup, outPayloads, outPendingModules);
}
}
}
_processModuleImport(
followup: ModuleImportPayload,
outPayloads: Array<RelayResponsePayload>,
outPendingModules: Array<Promise<NormalizationResult>>,
): void {
const operationLoader = this._operationLoader;
if (operationLoader == null) {
return;
}
// Try sync first
const node: ?NormalizationRootNode = operationLoader.get(
followup.operationReference,
);
if (node != null) {
const result = this._normalizeFollowup(followup, node);
outPayloads.push(...result.payloads);
outPendingModules.push(...result.pendingModules);
return;
}
// Async: return a Promise that resolves to the full NormalizationResult,
// preserving any nested payloads and further pending modules produced by
// the followup. The caller drains pendingModules recursively (see
// ReactiveQueryExecutionNode_EXPERIMENTAL.executeWithNetwork) — this
// mirrors OperationExecutor's recursive `_processPayloadFollowups`.
const emptyResult: NormalizationResult = {
payloads: [],
pendingModules: [],
};
outPendingModules.push(
operationLoader
.load(followup.operationReference)
.then((loadedNode: ?NormalizationRootNode) =>
loadedNode != null
? this._normalizeFollowup(followup, loadedNode)
: emptyResult,
)
// TODO(skyyao): surface async @module load errors instead of silently
// swallowing — OperationExecutor terminates execution via sink.error().
.catch((_error: unknown) => emptyResult),
);
}
_normalizeFollowup(
followup: ModuleImportPayload,
node: NormalizationRootNode,
): NormalizationResult {
const operationNode =
node.kind === 'SplitOperation' ? node : node.operation;
// TODO(skyyao): for SplitOperation followups, bind variables via
// getLocalVariables(followup.variables, operationNode.argumentDefinitions,
// followup.args) like OperationExecutor._normalizeFollowupPayload — current
// pass-through breaks @module argument binding.
// TODO(skyyao): propagate is_final extension on the synthetic response when
// in loading_final state, to enable nested defer/3D processing on
// non-streaming server mode (see OperationExecutor.js:864-870).
const selector = createNormalizationSelector(
operationNode,
followup.dataID,
followup.variables,
);
const payload = this._normalizeResponse(
{data: followup.data} as $FlowFixMe,
selector,
followup.typeName,
{
...this._options,
path: followup.path,
},
this._useExecTimeResolvers,
);
const extraPayloads: Array<RelayResponsePayload> = [];
const pendingModules: Array<Promise<NormalizationResult>> = [];
// Register nested placeholders
if (
payload.incrementalPlaceholders != null &&
payload.incrementalPlaceholders.length > 0
) {
this._registerPlaceholders(
payload.incrementalPlaceholders,
payload.source,
payload.fieldPayloads,
extraPayloads,
pendingModules,
);
}
// Process nested followups
if (
payload.followupPayloads != null &&
payload.followupPayloads.length > 0
) {
this._processFollowups(
payload.followupPayloads,
extraPayloads,
pendingModules,
);
}
const primaryPayload: RelayResponsePayload = {
...payload,
followupPayloads: null,
incrementalPlaceholders: null,
isFinal: this._computeIsFinal(payload.isFinal),
isPreNormalized: true,
};
return {
payloads: [primaryPayload, ...extraPayloads],
pendingModules,
};
}
// ---------------------------------------------------------------------------
// Private: completion tracking
// ---------------------------------------------------------------------------
_computeIsFinal(serverIsFinal: boolean): boolean {
return (
(serverIsFinal || this._serverComplete) &&
this._bufferedResponses.size === 0
);
}
}
/**
* Build a unique key for matching incremental responses to placeholders.
*/
function makeKey(label: string, pathKey: string): string {
return `${label}::${pathKey}`;
}
module.exports = NormalizationEngine;