UNPKG

@apollo/client

Version:

A fully-featured caching GraphQL client.

529 lines 27.8 kB
import { __assign } from "tslib"; import { invariant, newInvariantError } from "../../utilities/globals/index.js"; import { equal } from "@wry/equality"; import { Trie } from "@wry/trie"; import { Kind } from "graphql"; import { getFragmentFromSelection, getDefaultValues, getOperationDefinition, getTypenameFromResult, makeReference, isField, resultKeyNameFromField, isReference, shouldInclude, cloneDeep, addTypenameToDocument, isNonEmptyArray, argumentsObjectFromField, canonicalStringify, } from "../../utilities/index.js"; import { isArray, makeProcessedFieldsMerger, fieldNameFromStoreName, storeValueIsStoreObject, extractFragmentContext, } from "./helpers.js"; import { normalizeReadFieldOptions } from "./policies.js"; // Since there are only four possible combinations of context.clientOnly and // context.deferred values, we should need at most four "flavors" of any given // WriteContext. To avoid creating multiple copies of the same context, we cache // the contexts in the context.flavors Map (shared by all flavors) according to // their clientOnly and deferred values (always in that order). function getContextFlavor(context, clientOnly, deferred) { var key = "".concat(clientOnly).concat(deferred); var flavored = context.flavors.get(key); if (!flavored) { context.flavors.set(key, (flavored = context.clientOnly === clientOnly && context.deferred === deferred ? context : __assign(__assign({}, context), { clientOnly: clientOnly, deferred: deferred }))); } return flavored; } var StoreWriter = /** @class */ (function () { function StoreWriter(cache, reader, fragments) { this.cache = cache; this.reader = reader; this.fragments = fragments; } StoreWriter.prototype.writeToStore = function (store, _a) { var _this = this; var query = _a.query, result = _a.result, dataId = _a.dataId, variables = _a.variables, overwrite = _a.overwrite; var operationDefinition = getOperationDefinition(query); var merger = makeProcessedFieldsMerger(); variables = __assign(__assign({}, getDefaultValues(operationDefinition)), variables); var context = __assign(__assign({ store: store, written: Object.create(null), merge: function (existing, incoming) { return merger.merge(existing, incoming); }, variables: variables, varString: canonicalStringify(variables) }, extractFragmentContext(query, this.fragments)), { overwrite: !!overwrite, incomingById: new Map(), clientOnly: false, deferred: false, flavors: new Map() }); var ref = this.processSelectionSet({ result: result || Object.create(null), dataId: dataId, selectionSet: operationDefinition.selectionSet, mergeTree: { map: new Map() }, context: context, }); if (!isReference(ref)) { throw newInvariantError(11, result); } // So far, the store has not been modified, so now it's time to process // context.incomingById and merge those incoming fields into context.store. context.incomingById.forEach(function (_a, dataId) { var storeObject = _a.storeObject, mergeTree = _a.mergeTree, fieldNodeSet = _a.fieldNodeSet; var entityRef = makeReference(dataId); if (mergeTree && mergeTree.map.size) { var applied = _this.applyMerges(mergeTree, entityRef, storeObject, context); if (isReference(applied)) { // Assume References returned by applyMerges have already been merged // into the store. See makeMergeObjectsFunction in policies.ts for an // example of how this can happen. return; } // Otherwise, applyMerges returned a StoreObject, whose fields we should // merge into the store (see store.merge statement below). storeObject = applied; } if (globalThis.__DEV__ !== false && !context.overwrite) { var fieldsWithSelectionSets_1 = Object.create(null); fieldNodeSet.forEach(function (field) { if (field.selectionSet) { fieldsWithSelectionSets_1[field.name.value] = true; } }); var hasSelectionSet_1 = function (storeFieldName) { return fieldsWithSelectionSets_1[fieldNameFromStoreName(storeFieldName)] === true; }; var hasMergeFunction_1 = function (storeFieldName) { var childTree = mergeTree && mergeTree.map.get(storeFieldName); return Boolean(childTree && childTree.info && childTree.info.merge); }; Object.keys(storeObject).forEach(function (storeFieldName) { // If a merge function was defined for this field, trust that it // did the right thing about (not) clobbering data. If the field // has no selection set, it's a scalar field, so it doesn't need // a merge function (even if it's an object, like JSON data). if (hasSelectionSet_1(storeFieldName) && !hasMergeFunction_1(storeFieldName)) { warnAboutDataLoss(entityRef, storeObject, storeFieldName, context.store); } }); } store.merge(dataId, storeObject); }); // Any IDs written explicitly to the cache will be retained as // reachable root IDs for garbage collection purposes. Although this // logic includes root IDs like ROOT_QUERY and ROOT_MUTATION, their // retainment counts are effectively ignored because cache.gc() always // includes them in its root ID set. store.retain(ref.__ref); return ref; }; StoreWriter.prototype.processSelectionSet = function (_a) { var _this = this; var dataId = _a.dataId, result = _a.result, selectionSet = _a.selectionSet, context = _a.context, // This object allows processSelectionSet to report useful information // to its callers without explicitly returning that information. mergeTree = _a.mergeTree; var policies = this.cache.policies; // This variable will be repeatedly updated using context.merge to // accumulate all fields that need to be written into the store. var incoming = Object.create(null); // If typename was not passed in, infer it. Note that typename is // always passed in for tricky-to-infer cases such as "Query" for // ROOT_QUERY. var typename = (dataId && policies.rootTypenamesById[dataId]) || getTypenameFromResult(result, selectionSet, context.fragmentMap) || (dataId && context.store.get(dataId, "__typename")); if ("string" === typeof typename) { incoming.__typename = typename; } // This readField function will be passed as context.readField in the // KeyFieldsContext object created within policies.identify (called below). // In addition to reading from the existing context.store (thanks to the // policies.readField(options, context) line at the very bottom), this // version of readField can read from Reference objects that are currently // pending in context.incomingById, which is important whenever keyFields // need to be extracted from a child object that processSelectionSet has // turned into a Reference. var readField = function () { var options = normalizeReadFieldOptions(arguments, incoming, context.variables); if (isReference(options.from)) { var info = context.incomingById.get(options.from.__ref); if (info) { var result_1 = policies.readField(__assign(__assign({}, options), { from: info.storeObject }), context); if (result_1 !== void 0) { return result_1; } } } return policies.readField(options, context); }; var fieldNodeSet = new Set(); this.flattenFields(selectionSet, result, // This WriteContext will be the default context value for fields returned // by the flattenFields method, but some fields may be assigned a modified // context, depending on the presence of @client and other directives. context, typename).forEach(function (context, field) { var _a; var resultFieldKey = resultKeyNameFromField(field); var value = result[resultFieldKey]; fieldNodeSet.add(field); if (value !== void 0) { var storeFieldName = policies.getStoreFieldName({ typename: typename, fieldName: field.name.value, field: field, variables: context.variables, }); var childTree = getChildMergeTree(mergeTree, storeFieldName); var incomingValue = _this.processFieldValue(value, field, // Reset context.clientOnly and context.deferred to their default // values before processing nested selection sets. field.selectionSet ? getContextFlavor(context, false, false) : context, childTree); // To determine if this field holds a child object with a merge function // defined in its type policy (see PR #7070), we need to figure out the // child object's __typename. var childTypename = void 0; // The field's value can be an object that has a __typename only if the // field has a selection set. Otherwise incomingValue is scalar. if (field.selectionSet && (isReference(incomingValue) || storeValueIsStoreObject(incomingValue))) { childTypename = readField("__typename", incomingValue); } var merge = policies.getMergeFunction(typename, field.name.value, childTypename); if (merge) { childTree.info = { // TODO Check compatibility against any existing childTree.field? field: field, typename: typename, merge: merge, }; } else { maybeRecycleChildMergeTree(mergeTree, storeFieldName); } incoming = context.merge(incoming, (_a = {}, _a[storeFieldName] = incomingValue, _a)); } else if (globalThis.__DEV__ !== false && !context.clientOnly && !context.deferred && !addTypenameToDocument.added(field) && // If the field has a read function, it may be a synthetic field or // provide a default value, so its absence from the written data should // not be cause for alarm. !policies.getReadFunction(typename, field.name.value)) { globalThis.__DEV__ !== false && invariant.error(12, resultKeyNameFromField(field), result); } }); // Identify the result object, even if dataId was already provided, // since we always need keyObject below. try { var _b = policies.identify(result, { typename: typename, selectionSet: selectionSet, fragmentMap: context.fragmentMap, storeObject: incoming, readField: readField, }), id = _b[0], keyObject = _b[1]; // If dataId was not provided, fall back to the id just generated by // policies.identify. dataId = dataId || id; // Write any key fields that were used during identification, even if // they were not mentioned in the original query. if (keyObject) { // TODO Reverse the order of the arguments? incoming = context.merge(incoming, keyObject); } } catch (e) { // If dataId was provided, tolerate failure of policies.identify. if (!dataId) throw e; } if ("string" === typeof dataId) { var dataRef = makeReference(dataId); // Avoid processing the same entity object using the same selection // set more than once. We use an array instead of a Set since most // entity IDs will be written using only one selection set, so the // size of this array is likely to be very small, meaning indexOf is // likely to be faster than Set.prototype.has. var sets = context.written[dataId] || (context.written[dataId] = []); if (sets.indexOf(selectionSet) >= 0) return dataRef; sets.push(selectionSet); // If we're about to write a result object into the store, but we // happen to know that the exact same (===) result object would be // returned if we were to reread the result with the same inputs, // then we can skip the rest of the processSelectionSet work for // this object, and immediately return a Reference to it. if (this.reader && this.reader.isFresh(result, dataRef, selectionSet, context)) { return dataRef; } var previous_1 = context.incomingById.get(dataId); if (previous_1) { previous_1.storeObject = context.merge(previous_1.storeObject, incoming); previous_1.mergeTree = mergeMergeTrees(previous_1.mergeTree, mergeTree); fieldNodeSet.forEach(function (field) { return previous_1.fieldNodeSet.add(field); }); } else { context.incomingById.set(dataId, { storeObject: incoming, // Save a reference to mergeTree only if it is not empty, because // empty MergeTrees may be recycled by maybeRecycleChildMergeTree and // reused for entirely different parts of the result tree. mergeTree: mergeTreeIsEmpty(mergeTree) ? void 0 : mergeTree, fieldNodeSet: fieldNodeSet, }); } return dataRef; } return incoming; }; StoreWriter.prototype.processFieldValue = function (value, field, context, mergeTree) { var _this = this; if (!field.selectionSet || value === null) { // In development, we need to clone scalar values so that they can be // safely frozen with maybeDeepFreeze in readFromStore.ts. In production, // it's cheaper to store the scalar values directly in the cache. return globalThis.__DEV__ !== false ? cloneDeep(value) : value; } if (isArray(value)) { return value.map(function (item, i) { var value = _this.processFieldValue(item, field, context, getChildMergeTree(mergeTree, i)); maybeRecycleChildMergeTree(mergeTree, i); return value; }); } return this.processSelectionSet({ result: value, selectionSet: field.selectionSet, context: context, mergeTree: mergeTree, }); }; // Implements https://spec.graphql.org/draft/#sec-Field-Collection, but with // some additions for tracking @client and @defer directives. StoreWriter.prototype.flattenFields = function (selectionSet, result, context, typename) { if (typename === void 0) { typename = getTypenameFromResult(result, selectionSet, context.fragmentMap); } var fieldMap = new Map(); var policies = this.cache.policies; var limitingTrie = new Trie(false); // No need for WeakMap, since limitingTrie does not escape. (function flatten(selectionSet, inheritedContext) { var visitedNode = limitingTrie.lookup(selectionSet, // Because we take inheritedClientOnly and inheritedDeferred into // consideration here (in addition to selectionSet), it's possible for // the same selection set to be flattened more than once, if it appears // in the query with different @client and/or @directive configurations. inheritedContext.clientOnly, inheritedContext.deferred); if (visitedNode.visited) return; visitedNode.visited = true; selectionSet.selections.forEach(function (selection) { if (!shouldInclude(selection, context.variables)) return; var clientOnly = inheritedContext.clientOnly, deferred = inheritedContext.deferred; if ( // Since the presence of @client or @defer on this field can only // cause clientOnly or deferred to become true, we can skip the // forEach loop if both clientOnly and deferred are already true. !(clientOnly && deferred) && isNonEmptyArray(selection.directives)) { selection.directives.forEach(function (dir) { var name = dir.name.value; if (name === "client") clientOnly = true; if (name === "defer") { var args = argumentsObjectFromField(dir, context.variables); // The @defer directive takes an optional args.if boolean // argument, similar to @include(if: boolean). Note that // @defer(if: false) does not make context.deferred false, but // instead behaves as if there was no @defer directive. if (!args || args.if !== false) { deferred = true; } // TODO In the future, we may want to record args.label using // context.deferred, if a label is specified. } }); } if (isField(selection)) { var existing = fieldMap.get(selection); if (existing) { // If this field has been visited along another recursive path // before, the final context should have clientOnly or deferred set // to true only if *all* paths have the directive (hence the &&). clientOnly = clientOnly && existing.clientOnly; deferred = deferred && existing.deferred; } fieldMap.set(selection, getContextFlavor(context, clientOnly, deferred)); } else { var fragment = getFragmentFromSelection(selection, context.lookupFragment); if (!fragment && selection.kind === Kind.FRAGMENT_SPREAD) { throw newInvariantError(13, selection.name.value); } if (fragment && policies.fragmentMatches(fragment, typename, result, context.variables)) { flatten(fragment.selectionSet, getContextFlavor(context, clientOnly, deferred)); } } }); })(selectionSet, context); return fieldMap; }; StoreWriter.prototype.applyMerges = function (mergeTree, existing, incoming, context, getStorageArgs) { var _a; var _this = this; if (mergeTree.map.size && !isReference(incoming)) { var e_1 = // Items in the same position in different arrays are not // necessarily related to each other, so when incoming is an array // we process its elements as if there was no existing data. (!isArray(incoming) && // Likewise, existing must be either a Reference or a StoreObject // in order for its fields to be safe to merge with the fields of // the incoming object. (isReference(existing) || storeValueIsStoreObject(existing))) ? existing : void 0; // This narrowing is implied by mergeTree.map.size > 0 and // !isReference(incoming), though TypeScript understandably cannot // hope to infer this type. var i_1 = incoming; // The options.storage objects provided to read and merge functions // are derived from the identity of the parent object plus a // sequence of storeFieldName strings/numbers identifying the nested // field name path of each field value to be merged. if (e_1 && !getStorageArgs) { getStorageArgs = [isReference(e_1) ? e_1.__ref : e_1]; } // It's possible that applying merge functions to this subtree will // not change the incoming data, so this variable tracks the fields // that did change, so we can create a new incoming object when (and // only when) at least one incoming field has changed. We use a Map // to preserve the type of numeric keys. var changedFields_1; var getValue_1 = function (from, name) { return (isArray(from) ? typeof name === "number" ? from[name] : void 0 : context.store.getFieldValue(from, String(name))); }; mergeTree.map.forEach(function (childTree, storeFieldName) { var eVal = getValue_1(e_1, storeFieldName); var iVal = getValue_1(i_1, storeFieldName); // If we have no incoming data, leave any existing data untouched. if (void 0 === iVal) return; if (getStorageArgs) { getStorageArgs.push(storeFieldName); } var aVal = _this.applyMerges(childTree, eVal, iVal, context, getStorageArgs); if (aVal !== iVal) { changedFields_1 = changedFields_1 || new Map(); changedFields_1.set(storeFieldName, aVal); } if (getStorageArgs) { invariant(getStorageArgs.pop() === storeFieldName); } }); if (changedFields_1) { // Shallow clone i so we can add changed fields to it. incoming = (isArray(i_1) ? i_1.slice(0) : __assign({}, i_1)); changedFields_1.forEach(function (value, name) { incoming[name] = value; }); } } if (mergeTree.info) { return this.cache.policies.runMergeFunction(existing, incoming, mergeTree.info, context, getStorageArgs && (_a = context.store).getStorage.apply(_a, getStorageArgs)); } return incoming; }; return StoreWriter; }()); export { StoreWriter }; var emptyMergeTreePool = []; function getChildMergeTree(_a, name) { var map = _a.map; if (!map.has(name)) { map.set(name, emptyMergeTreePool.pop() || { map: new Map() }); } return map.get(name); } function mergeMergeTrees(left, right) { if (left === right || !right || mergeTreeIsEmpty(right)) return left; if (!left || mergeTreeIsEmpty(left)) return right; var info = left.info && right.info ? __assign(__assign({}, left.info), right.info) : left.info || right.info; var needToMergeMaps = left.map.size && right.map.size; var map = needToMergeMaps ? new Map() : left.map.size ? left.map : right.map; var merged = { info: info, map: map }; if (needToMergeMaps) { var remainingRightKeys_1 = new Set(right.map.keys()); left.map.forEach(function (leftTree, key) { merged.map.set(key, mergeMergeTrees(leftTree, right.map.get(key))); remainingRightKeys_1.delete(key); }); remainingRightKeys_1.forEach(function (key) { merged.map.set(key, mergeMergeTrees(right.map.get(key), left.map.get(key))); }); } return merged; } function mergeTreeIsEmpty(tree) { return !tree || !(tree.info || tree.map.size); } function maybeRecycleChildMergeTree(_a, name) { var map = _a.map; var childTree = map.get(name); if (childTree && mergeTreeIsEmpty(childTree)) { emptyMergeTreePool.push(childTree); map.delete(name); } } var warnings = new Set(); // Note that this function is unused in production, and thus should be // pruned by any well-configured minifier. function warnAboutDataLoss(existingRef, incomingObj, storeFieldName, store) { var getChild = function (objOrRef) { var child = store.getFieldValue(objOrRef, storeFieldName); return typeof child === "object" && child; }; var existing = getChild(existingRef); if (!existing) return; var incoming = getChild(incomingObj); if (!incoming) return; // It's always safe to replace a reference, since it refers to data // safely stored elsewhere. if (isReference(existing)) return; // If the values are structurally equivalent, we do not need to worry // about incoming replacing existing. if (equal(existing, incoming)) return; // If we're replacing every key of the existing object, then the // existing data would be overwritten even if the objects were // normalized, so warning would not be helpful here. if (Object.keys(existing).every(function (key) { return store.getFieldValue(incoming, key) !== void 0; })) { return; } var parentType = store.getFieldValue(existingRef, "__typename") || store.getFieldValue(incomingObj, "__typename"); var fieldName = fieldNameFromStoreName(storeFieldName); var typeDotName = "".concat(parentType, ".").concat(fieldName); // Avoid warning more than once for the same type and field name. if (warnings.has(typeDotName)) return; warnings.add(typeDotName); var childTypenames = []; // Arrays do not have __typename fields, and always need a custom merge // function, even if their elements are normalized entities. if (!isArray(existing) && !isArray(incoming)) { [existing, incoming].forEach(function (child) { var typename = store.getFieldValue(child, "__typename"); if (typeof typename === "string" && !childTypenames.includes(typename)) { childTypenames.push(typename); } }); } globalThis.__DEV__ !== false && invariant.warn(14, fieldName, parentType, childTypenames.length ? "either ensure all objects of type " + childTypenames.join(" and ") + " have an ID or a custom merge function, or " : "", typeDotName, __assign({}, existing), __assign({}, incoming)); } //# sourceMappingURL=writeToStore.js.map