UNPKG

relay-runtime

Version:

A core runtime for building GraphQL-driven applications.

504 lines (453 loc) 15 kB
/** * 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 * @oncall relay */ import type {ActorIdentifier} from '../multi-actor-environment/ActorIdentifier'; import type {PayloadData} from '../network/RelayNetworkTypes'; import type { NormalizationField, NormalizationLinkedField, NormalizationNode, } from '../util/NormalizationNode'; import type {DataID, Variables} from '../util/RelayRuntimeTypes'; import type {NormalizationOptions} from './RelayResponseNormalizer'; import type {GetDataID} from './RelayResponseNormalizer'; import type { IncrementalDataPlaceholder, NormalizationSelector, } from './RelayStoreTypes'; const { CLIENT_EXTENSION, CONDITION, DEFER, FRAGMENT_SPREAD, INLINE_FRAGMENT, LINKED_FIELD, LINKED_HANDLE, SCALAR_FIELD, SCALAR_HANDLE, } = require('../util/RelayConcreteNode'); const {getLocalVariables} = require('./RelayConcreteVariables'); const {createNormalizationSelector} = require('./RelayModernSelector'); const {ROOT_TYPE, TYPENAME_KEY, getStorageKey} = require('./RelayStoreUtils'); const invariant = require('invariant'); const {generateClientID} = require('relay-runtime'); /** * This module is an experiment to explore a proposal normalized response format for GraphQL. * See the Quip document: Canonical Normalized Response Format (“GraphMode”) Proposal */ /** * # TODO * * - [ ] Compute storage keys using method outlined in the proposal * - [ ] Plural fields * - [ ] Write a utility to populate the store using a GraphMode response. */ export type ScalarField = string | number | null; export type LinkedField = | { __id: number, } | { __ids: Array<number | null>, }; export type ChunkField = ScalarField | Array<ScalarField> | LinkedField; export type ChunkFields = { [string]: ChunkField, }; export type RecordChunk = { $kind: 'Record', $streamID: number, __id: string, __typename: string, [string]: ChunkField, }; export type ExtendChunk = { $kind: 'Extend', $streamID: number, [string]: ChunkField, }; export type CompleteChunk = { $kind: 'Complete', }; export type DataChunk = RecordChunk | ExtendChunk; export type GraphModeChunk = DataChunk | CompleteChunk; export type GraphModeResponse = Iterable<GraphModeChunk>; export type TransformMetadata = { duplicateFieldsAvoided: number, }; /** * Converts a JSON response (and Normalization AST) into a stream of GraphMode chunks * * The stream is modeled as a Generator in order to highlight the streaming * nature of the response. Once a chunk is generated, it can be immediately flushed * to the client. * * The response is traversed depth-first, meaning children are emitted before * the parent. This allows parent objects to reference children using their * `$streamID`. * * After each object is traversed, a chunk is emitted. The first time an object * -- identified by its strong ID -- is encountered we emit a `Record`, and its * `$streamID` is recorded. If that same object is encountered again later in * the response, an `Extend` chunk is emitted, which includes any previously * unsent fields. If no unsent fields are present in the second appearance of * the new object, no chunk is emitted. * * ## State * * As we traverse we must maintain some state: * * - The next streamID * - A mapping of cache keys to streamIDs * - The set of fields which we've sent for each streamID. This allows us to * avoid sending fields twice. */ export function normalizeResponse( response: PayloadData, selector: NormalizationSelector, options: NormalizationOptions, ): GraphModeResponse { const {node, variables, dataID} = selector; const normalizer = new GraphModeNormalizer(variables, options); return normalizer.normalizeResponse(node, dataID, response); } export function normalizeResponseWithMetadata( response: PayloadData, selector: NormalizationSelector, options: NormalizationOptions, ): [Array<GraphModeChunk>, TransformMetadata] { const {node, variables, dataID} = selector; const normalizer = new GraphModeNormalizer(variables, options); const chunks = Array.from( normalizer.normalizeResponse(node, dataID, response), ); return [chunks, {duplicateFieldsAvoided: normalizer.duplicateFieldsAvoided}]; } export class GraphModeNormalizer { _cacheKeyToStreamID: Map<string, number>; _sentFields: Map<string, Set<string>>; _nextStreamID: number; _getDataID: GetDataID; _variables: Variables; _path: Array<string>; _incrementalPlaceholders: Array<IncrementalDataPlaceholder>; _actorIdentifier: ?ActorIdentifier; duplicateFieldsAvoided: number; constructor(variables: Variables, options: NormalizationOptions) { this._actorIdentifier = options.actorIdentifier; this._path = options.path ? [...options.path] : []; this._getDataID = options.getDataID; this._cacheKeyToStreamID = new Map(); this._sentFields = new Map(); this._nextStreamID = 0; this._variables = variables; this.duplicateFieldsAvoided = 0; } _getStreamID(): number { return this._nextStreamID++; } _getSentFields(cacheKey: string): Set<string> { const maybeSent = this._sentFields.get(cacheKey); if (maybeSent != null) { return maybeSent; } const sent = new Set<string>(); this._sentFields.set(cacheKey, sent); return sent; } _getObjectType(data: PayloadData): string { const typeName = (data: any)[TYPENAME_KEY]; invariant( typeName != null, 'Expected a typename for record `%s`.', JSON.stringify(data, null, 2), ); return typeName; } // TODO: The GraphMode proposal outlines different approachs to derive keys. We // can expriment with different approaches here. _getStorageKey(selection: NormalizationField): string { return getStorageKey(selection, this._variables); } _getVariableValue(name: string): mixed { invariant( this._variables.hasOwnProperty(name), 'Unexpected undefined variable `%s`.', name, ); return this._variables[name]; } *normalizeResponse( node: NormalizationNode, dataID: DataID, data: PayloadData, ): Generator<GraphModeChunk, void, void> { const rootFields: ChunkFields = {}; yield* this._traverseSelections(node, data, rootFields, dataID, new Set()); const $streamID = this._getStreamID(); yield { ...rootFields, $kind: 'Record', $streamID, __id: dataID, __typename: ROOT_TYPE, }; yield { $kind: 'Complete', }; } *_flushFields( cacheKey: string, typename: string, fields: ChunkFields, ): Generator<GraphModeChunk, number, void> { const maybeStreamID = this._cacheKeyToStreamID.get(cacheKey); const $streamID = maybeStreamID ?? this._getStreamID(); if (maybeStreamID == null) { this._cacheKeyToStreamID.set(cacheKey, $streamID); // TODO: We could mutate `fields` rather than constructing a new // chunk object, but it's hard to convince Flow that we've // constructed a valid Chunk, and perf is not important for this // experimental transform yield { ...fields, $kind: 'Record', __typename: typename, __id: cacheKey, $streamID, }; } else if (Object.keys(fields).length > 0) { yield {...fields, $kind: 'Extend', $streamID}; } return $streamID; } *_traverseSelections( node: NormalizationNode, data: PayloadData, parentFields: ChunkFields, parentID: string, sentFields: Set<string>, ): Generator<GraphModeChunk, void, void> { const selections = node.selections; for (const selection of selections) { switch (selection.kind) { case LINKED_FIELD: { const responseKey = selection.alias ?? selection.name; const fieldData: PayloadData = (data[responseKey]: any); const storageKey = this._getStorageKey(selection); this._path.push(responseKey); const fieldValue = yield* this._traverseLinkedField( selection.plural, fieldData, storageKey, selection, parentID, ); this._path.pop(); // TODO: We could also opt to confirm that this matches the previously // seen value. if (sentFields.has(storageKey)) { this.duplicateFieldsAvoided++; break; } parentFields[storageKey] = fieldValue; sentFields.add(storageKey); break; } case SCALAR_FIELD: { const responseKey = selection.alias ?? selection.name; const storageKey = this._getStorageKey(selection); // TODO: We could also opt to confirm that this matches the previously // seen value. if (sentFields.has(storageKey)) { this.duplicateFieldsAvoided++; break; } const fieldData: ChunkField = (data[responseKey]: any); parentFields[storageKey] = fieldData; sentFields.add(storageKey); break; } case INLINE_FRAGMENT: { const objType = this._getObjectType(data); const {abstractKey} = selection; if (abstractKey == null) { if (objType !== selection.type) { break; } } else if (!data.hasOwnProperty(abstractKey)) { break; } yield* this._traverseSelections( selection, data, parentFields, parentID, sentFields, ); break; } case FRAGMENT_SPREAD: { const prevVariables = this._variables; this._variables = getLocalVariables( this._variables, selection.fragment.argumentDefinitions, selection.args, ); yield* this._traverseSelections( selection.fragment, data, parentFields, parentID, sentFields, ); this._variables = prevVariables; break; } case CONDITION: const conditionValue = Boolean( this._getVariableValue(selection.condition), ); if (conditionValue === selection.passingValue) { yield* this._traverseSelections( selection, data, parentFields, parentID, sentFields, ); } break; case DEFER: const isDeferred = selection.if === null || this._getVariableValue(selection.if); if (isDeferred === false) { // If defer is disabled there will be no additional response chunk: // normalize the data already present. yield* this._traverseSelections( selection, data, parentFields, parentID, sentFields, ); } 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: selection.label, path: [...this._path], selector: createNormalizationSelector( selection, parentID, this._variables, ), typeName: this._getObjectType(data), actorIdentifier: this._actorIdentifier, }); } break; case CLIENT_EXTENSION: // Since we are only expecting to handle server responses, we can skip // over client extensions. break; case SCALAR_HANDLE: case LINKED_HANDLE: // Handles allow us to record information that will be needed to // perform additional process when we insert data into the store. For // example, connection edges need to be prepended/appended to the // pre-existing values. // // GraphMode will eventually need some replacement for this, but it is // not nessesary in order to measure things like response size, so we // can ignore these for now. break; default: throw new Error(`Unexpected selection type: ${selection.kind}`); } } } *_traverseLinkedField( plural: boolean, fieldData: PayloadData, storageKey: string, selection: NormalizationLinkedField, parentID: string, index?: number, ): Generator<GraphModeChunk, ChunkField, void> { if (fieldData == null) { return null; } if (plural) { invariant( Array.isArray(fieldData), `Expected fieldData to be an array. Got ${JSON.stringify(fieldData)}`, ); const fieldValue = []; for (const [i, itemData] of fieldData.entries()) { this._path.push(String(i)); const itemValue = yield* this._traverseLinkedField( false, itemData, storageKey, selection, parentID, i, ); this._path.pop(); fieldValue.push(itemValue); } const ids = fieldValue.map(value => { if (value == null) { return null; } invariant( typeof value.__id === 'number', 'Expected objects in a plural linked field to have an __id.', ); return value.__id; }); return {__ids: ids}; } invariant( typeof fieldData === 'object', 'Expected data for field `%s` to be an object.', storageKey, ); const objType = selection.concreteType ?? this._getObjectType(fieldData); const nextID = this._getDataID(fieldData, objType) || // Note: In RelayResponseNormalizer we try to access a cached // version of the key before generating a new one. I'm not clear if // that's a performance optimization (which would not be important // here) or important for stable ids. // TODO: The proposal does not yet specify how we handle objects // without strong ids. generateClientID(parentID, storageKey, index); invariant( typeof nextID === 'string', 'Expected id on field `%s` to be a string.', storageKey, ); const fields: ChunkFields = {}; // Yield any decendent record chunks, and mutatively populate direct fields. yield* this._traverseSelections( selection, fieldData, fields, nextID, this._getSentFields(nextID), ); const $streamID = yield* this._flushFields(nextID, objType, fields); return {__id: $streamID}; } }