@apollo/client
Version:
A fully-featured caching GraphQL client.
513 lines (511 loc) • 25.6 kB
JavaScript
"use strict";;
const {
__DEV__
} = require("@apollo/client/utilities/environment");
Object.defineProperty(exports, "__esModule", { value: true });
exports.QueryInfo = void 0;
const equality_1 = require("@wry/equality");
const trie_1 = require("@wry/trie");
const environment_1 = require("@apollo/client/utilities/environment");
const internal_1 = require("@apollo/client/utilities/internal");
const invariant_1 = require("@apollo/client/utilities/invariant");
const networkStatus_js_1 = require("./networkStatus.cjs");
const IGNORE = {};
const destructiveMethodCounts = new WeakMap();
function wrapDestructiveCacheMethod(cache, methodName) {
const original = cache[methodName];
if (typeof original === "function") {
// @ts-expect-error this is just too generic to be typed correctly
cache[methodName] = function () {
destructiveMethodCounts.set(cache,
// The %1e15 allows the count to wrap around to 0 safely every
// quadrillion evictions, so there's no risk of overflow. To be
// clear, this is more of a pedantic principle than something
// that matters in any conceivable practical scenario.
(destructiveMethodCounts.get(cache) + 1) % 1e15);
// @ts-expect-error this is just too generic to be typed correctly
return original.apply(this, arguments);
};
}
}
const queryInfoIds = new WeakMap();
// A QueryInfo object represents a single network request, either initiated
// from the QueryManager or from an ObservableQuery.
// It will only ever be used for a single network call.
// It is responsible for reporting results to the cache, merging and in a no-cache
// scenario accumulating the response.
class QueryInfo {
cache;
queryManager;
id;
observableQuery;
incremental;
constructor(queryManager, observableQuery) {
const cache = (this.cache = queryManager.cache);
const id = (queryInfoIds.get(queryManager) || 0) + 1;
queryInfoIds.set(queryManager, id);
this.id = id + "";
this.observableQuery = observableQuery;
this.queryManager = queryManager;
// Track how often cache.evict is called, since we want eviction to
// override the write-skipping logic in `shouldWrite`, by causing it to
// return true. Wrapping the cache.evict method is a bit of a hack, but it
// saves us from having to make eviction counting an official part of the
// ApolloCache API.
if (!destructiveMethodCounts.has(cache)) {
destructiveMethodCounts.set(cache, 0);
wrapDestructiveCacheMethod(cache, "evict");
wrapDestructiveCacheMethod(cache, "modify");
wrapDestructiveCacheMethod(cache, "reset");
}
}
/**
* @internal
* Tracks the last result written to the cache so that `shouldWrite` can skip
* an identical write. Since a `QueryInfo` only ever represents a single
* network request, this is shared by all `QueryInfo` instances of an
* `ObservableQuery`. A standalone `QueryInfo` keeps a local version.
*
* A network result that was explicitly asked for always takes precedence over
* what is already cached, so `ObservableQuery.refetch` and polling clear this
* value before starting their request.
*
* @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time.
*/
_lastWrite;
get lastWrite() {
return (this.observableQuery || this)._lastWrite;
}
set lastWrite(value) {
(this.observableQuery || this)._lastWrite = value;
}
resetLastWrite() {
this.lastWrite = void 0;
}
shouldWrite(result, variables) {
const { lastWrite } = this;
return (!lastWrite ||
// If cache.evict has been called since the last time we wrote this
// data into the cache, there's a chance writing this result into
// the cache will repair what was evicted.
lastWrite.dmCount !== destructiveMethodCounts.get(this.cache) ||
!(0, equality_1.equal)(variables, lastWrite.variables) ||
!(0, equality_1.equal)(result.data, lastWrite.result.data) ||
// We have to compare these values because its possible the final chunk
// emitted in the incremental result is just `hasNext: false`. This
// ensures we trigger a cache write when we get `isLastChunk: true`.
lastWrite.hasNext !== this.hasNext);
}
get hasNext() {
return this.incremental ? this.incremental.hasNext : false;
}
get incrementalHandler() {
return this.queryManager.incrementalHandler;
}
maybeHandleIncrementalResult(cacheData, incoming, query) {
if (this.incrementalHandler.isIncrementalResult(incoming)) {
this.incremental ||= this.incrementalHandler.startRequest({
query,
});
return this.incremental.handle(cacheData, incoming);
}
return incoming;
}
markQueryResult(incoming, { document: query, variables, errorPolicy, cacheWriteBehavior, returnPartialData, fetchPolicy, networkStatus, prunePendingDeferFragments: prune, }) {
const diffOptions = {
query,
variables,
optimistic: true,
};
// Cancel the pending notify timeout (if it exists) to prevent extraneous network
// requests. To allow future notify timeouts, diff and dirty are reset as well.
this.observableQuery?.["resetNotifications"]();
const skipCache = cacheWriteBehavior === 0 /* CacheWriteBehavior.FORBID */;
const diff = skipCache ? undefined : (this.getDiff({
...diffOptions,
// We usually request partial data to ensure the network incremental
// result is merged with all existing data (especially true to
// maintain @stream arrays with partial list items in the right order
// or when chunk might otherwise replace a partial non-normalized
// object), but if we are about to throw away the result anyways due
// to the error policy (which early returns below), prune any
// pending boundaries so that CombinedGraphQLErrors contains the
// right `data` value.
returnPartialData: errorPolicy !== "none" ||
!this.incrementalHandler.extractErrors(incoming)?.length,
}, this.getIncrementalInfo({ prune })));
const incrementalResult = this.maybeHandleIncrementalResult(diff?.result, incoming, query);
let result = {
...incrementalResult,
dataState: incrementalResult.data == null ? "empty" : "complete",
};
const hasPendingDefer = this.incremental
?.getPendingWithInfo?.()
.some((pending) => pending.type === "defer" && !pending.delivered);
if (hasPendingDefer ||
// The Defer20220824Handler cannot track pending/completed incremental
// chunks due to its data format so we naively set dataState to
// streaming if we are still processing chunks. The only case where
// streaming is incorrect and should actually be complete is when
// both a @defer and @stream boundary is present and the @defer chunk
// has completed before the `@stream` array.
//
// Assigning the naive "streaming" value avoids a much more expensive
// pass over `result.data` that would otherwise need to traverse the
// selection sets and evaluate the data object at each defer boundary
// to see if it fulfills the selection set. For such a narrow case where
// its incorrect on a format that is now outdated is not worth the
// fix so we are ok with reporting a `streaming` here.
(!this.incremental?.getPendingWithInfo &&
this.hasNext &&
(0, internal_1.hasDirectives)(["defer"], query))) {
result.dataState = "streaming";
}
if (skipCache) {
return result;
}
if (!shouldWriteResult(result, errorPolicy)) {
this.lastWrite = void 0;
return result;
}
let written = false;
// Using a transaction here so we have a chance to read the result
// back from the cache before the watch callback fires as a result
// of writeQuery, so we can store the new diff quietly and ignore
// it when we receive it redundantly from the watch callback.
this.cache.batch({
onWatchUpdated: (
// all additional options on ObservableQuery.CacheWatchOptions are
// optional so we can use the type here
watch, diff) => {
if (watch.watcher === this.observableQuery) {
// see comment on `lastOwnDiff` for explanation
watch.lastOwnDiff = diff;
}
},
update: (cache) => {
const shouldWrite = this.shouldWrite(result, variables);
// If result is the same as the last result we received from
// the network (and the variables match too), avoid writing
// result into the cache again. The wisdom of skipping this
// cache write is far from obvious, since any cache write
// could be the one that puts the cache back into a desired
// state, fixing corruption or missing data. However, if we
// always write every network result into the cache, we enable
// feuds between queries competing to update the same data in
// incompatible ways, which can lead to an endless cycle of
// cache broadcasts and useless network requests. As with any
// feud, eventually one side must step back from the brink,
// letting the other side(s) have the last word(s). There may
// be other points where we could break this cycle, such as
// silencing the broadcast for cache.writeQuery (not a good
// idea, since it just delays the feud a bit) or somehow
// avoiding the network request that just happened (also bad,
// because the server could return useful new data). All
// options considered, skipping this cache write seems to be
// the least damaging place to break the cycle, because it
// reflects the intuition that we recently wrote this exact
// result into the cache, so the cache *should* already/still
// contain this data. If some other query has clobbered that
// data in the meantime, that's too bad, but there will be no
// winners if every query blindly reverts to its own version
// of the data. This approach also gives the network a chance
// to return new data, which will be written into the cache as
// usual, notifying only those queries that are directly
// affected by the cache updates, as usual. In the future, an
// even more sophisticated cache could perhaps prevent or
// mitigate the clobbering somehow, but that would make this
// particular cache write even less important, and thus
// skipping it would be even safer than it is today.
if (shouldWrite) {
cache.writeQuery({
query,
data: result.data,
variables,
overwrite: cacheWriteBehavior === 1 /* CacheWriteBehavior.OVERWRITE */,
extensions: result.extensions,
});
this.lastWrite = {
result,
variables,
dmCount: destructiveMethodCounts.get(this.cache),
hasNext: this.hasNext,
};
written = true;
}
const { dataState, result: diffResult } = this.getDiff({
...diffOptions,
returnPartialData: returnPartialData &&
// Never deliver partial data for network-only requests
(fetchPolicy !== "network-only" ||
networkStatus === networkStatus_js_1.NetworkStatus.refetch),
}, this.getIncrementalInfo({ prune }));
if (dataState === "complete" ||
dataState === "streaming" ||
(returnPartialData && dataState === "partial" && shouldWrite)) {
result = { ...result, data: diffResult, dataState };
}
else if (environment_1.__DEV__ &&
written &&
// A result that is still streaming is expected to read back
// incomplete until the remaining chunks arrive.
!this.hasNext) {
warnAboutPartialCacheResult(query, result.data,
// Always show the partial result for debugging, otherwise the user
// sees `null` when `returnPartialData` is false which isn't helpful
// for figuring out where the problem is.
cache.diff({ ...diffOptions, returnPartialData: true }));
}
},
});
return result;
}
getIncrementalInfo({ prune }) {
const pending = this.incremental?.getPendingWithInfo?.() ?? [];
const streamInfo = this.incremental?.streamInfo;
const incrementalInfo = { streamInfo };
// We don't want to deliver stream items or complete defer boundaries
// for a network-only request if they haven't yet streamed from the
// network. We record all the still-pending paths so that cache.diff
// can prune complete defer/stream boundaries at those paths.
if (prune) {
for (const item of pending) {
if (item.type === "defer" && !item.delivered) {
incrementalInfo.deferInfo ||= new trie_1.Trie(true, () => true);
incrementalInfo.deferInfo.lookupArray(item.path.concat(item.label || []));
}
else if (streamInfo && item.type === "stream") {
streamInfo.lookupArray(item.path).state.truncate = true;
}
}
}
return incrementalInfo;
}
getDiff(options, incrementalInfo) {
const diff = this.cache.diff({
...options,
[internal_1.handleIncrementalSymbol]: incrementalInfo,
});
if ("dataState" in diff) {
return diff;
}
return {
...diff,
dataState: diff.complete ? "complete"
: diff.result === null ? "empty"
: "partial",
};
}
markMutationResult(incoming, mutation, cache = this.cache) {
const cacheWrites = [];
const skipCache = mutation.cacheWriteBehavior === 0 /* CacheWriteBehavior.FORBID */;
let result = this.maybeHandleIncrementalResult(skipCache ? undefined : (cache.diff({
id: "ROOT_MUTATION",
// The cache complains if passed a mutation where it expects a
// query, so we transform mutations and subscriptions to queries
// (only once, thanks to this.transformCache).
query: this.queryManager.getDocumentInfo(mutation.document).asQuery,
variables: mutation.variables,
optimistic: false,
returnPartialData: true,
}).result), incoming, mutation.document);
if (mutation.errorPolicy === "ignore") {
result = { ...result, errors: [] };
}
if ((0, internal_1.graphQLResultHasError)(result) && mutation.errorPolicy === "none") {
return Promise.resolve(result);
}
const getResultWithDataState = () => ({
...result,
dataState: this.hasNext ? "streaming" : "complete",
});
if (!skipCache && shouldWriteResult(result, mutation.errorPolicy)) {
cacheWrites.push({
result: result.data,
dataId: "ROOT_MUTATION",
query: mutation.document,
variables: mutation.variables,
extensions: result.extensions,
});
const { updateQueries } = mutation;
if (updateQueries) {
this.queryManager
.getObservableQueries("all")
.forEach((observableQuery) => {
const queryName = observableQuery && observableQuery.queryName;
if (!queryName ||
!Object.hasOwnProperty.call(updateQueries, queryName)) {
return;
}
const updater = updateQueries[queryName];
const { query: document, variables } = observableQuery;
// Read the current query result from the store.
const { result: currentQueryResult, complete } = observableQuery.getCacheDiff({ optimistic: false });
if (complete && currentQueryResult) {
// Run our reducer using the current query result and the mutation result.
const nextQueryResult = updater(currentQueryResult, {
mutationResult: getResultWithDataState(),
queryName: (document && (0, internal_1.getOperationName)(document)) || void 0,
queryVariables: variables,
});
// Write the modified result back into the store if we got a new result.
if (nextQueryResult) {
cacheWrites.push({
result: nextQueryResult,
dataId: "ROOT_QUERY",
query: document,
variables,
});
}
}
});
}
}
let refetchQueries = mutation.refetchQueries;
if (typeof refetchQueries === "function") {
refetchQueries = refetchQueries(getResultWithDataState());
}
if (cacheWrites.length > 0 ||
(refetchQueries || "").length > 0 ||
mutation.update ||
mutation.onQueryUpdated ||
mutation.removeOptimistic) {
const results = [];
this.queryManager
.refetchQueries({
updateCache: (cache) => {
if (!skipCache) {
cacheWrites.forEach((write) => cache.write(write));
}
// If the mutation has some writes associated with it then we need to
// apply those writes to the store by running this reducer again with
// a write action.
const { update } = mutation;
// Determine whether result is a SingleExecutionResult,
// or the final ExecutionPatchResult.
// Re-read from the cache after writing to it to update `result`
// with any parsed scalar values that might have been written.
if (!skipCache) {
const diff = cache.diff({
id: "ROOT_MUTATION",
// The cache complains if passed a mutation where it expects a
// query, so we transform mutations and subscriptions to queries
// (only once, thanks to this.transformCache).
query: this.queryManager.getDocumentInfo(mutation.document)
.asQuery,
variables: mutation.variables,
optimistic: false,
returnPartialData: true,
});
if (diff.complete) {
result = {
...result,
data: diff.result,
};
}
}
// If we've received the whole response, call the update function.
if (update && !this.hasNext) {
update(cache, result, {
context: mutation.context,
variables: mutation.variables,
});
}
// TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it
// shallow to allow rolling back optimistic evictions.
if (!skipCache && !mutation.keepRootFields && !this.hasNext) {
cache.modify({
id: "ROOT_MUTATION",
fields(value, { fieldName, DELETE }) {
return fieldName === "__typename" ? value : DELETE;
},
});
}
},
include: refetchQueries,
// Write the final mutation.result to the root layer of the cache.
optimistic: false,
// Remove the corresponding optimistic layer at the same time as we
// write the final non-optimistic result.
removeOptimistic: mutation.removeOptimistic,
// Let the caller of client.mutate optionally determine the refetching
// behavior for watched queries after the mutation.update function runs.
// If no onQueryUpdated function was provided for this mutation, pass
// null instead of undefined to disable the default refetching behavior.
onQueryUpdated: mutation.onQueryUpdated || null,
})
.forEach((result) => results.push(result));
if (mutation.awaitRefetchQueries || mutation.onQueryUpdated) {
// Returning a promise here makes the mutation await that promise, so we
// include results in that promise's work if awaitRefetchQueries or an
// onQueryUpdated function was specified.
return Promise.all(results).then(() => result);
}
}
return Promise.resolve(result);
}
markMutationOptimistic(optimisticResponse, mutation) {
const data = typeof optimisticResponse === "function" ?
optimisticResponse(mutation.variables, { IGNORE })
: optimisticResponse;
if (data === IGNORE) {
return false;
}
this.cache.recordOptimisticTransaction((cache) => {
try {
this.markMutationResult({ data }, mutation, cache);
}
catch (error) {
invariant_1.invariant.error(error);
}
}, this.id);
return true;
}
markSubscriptionResult(result, { document, variables, errorPolicy, cacheWriteBehavior, }) {
if (cacheWriteBehavior !== 0 /* CacheWriteBehavior.FORBID */) {
if (shouldWriteResult(result, errorPolicy)) {
this.cache.write({
query: document,
result: result.data,
dataId: "ROOT_SUBSCRIPTION",
variables: variables,
extensions: result.extensions,
});
// Re-read from the cache to get parsed scalar values
const diff = this.cache.diff({
// The cache complains if passed a mutation where it expects a
// query, so we transform mutations and subscriptions to queries
// (only once, thanks to this.transformCache).
query: this.queryManager.getDocumentInfo(document).asQuery,
id: "ROOT_SUBSCRIPTION",
variables,
optimistic: false,
returnPartialData: true,
});
if (diff.complete) {
result.data = diff.result;
}
}
this.queryManager.broadcastQueries();
}
}
}
exports.QueryInfo = QueryInfo;
function warnAboutPartialCacheResult(query, networkResult, diff) {
__DEV__ && invariant_1.invariant.warn(
89,
(0, internal_1.getOperationName)(query, "(anonymous)"),
diff.missing?.missing,
networkResult,
diff.result
);
}
function shouldWriteResult(result, errorPolicy = "none") {
const ignoreErrors = errorPolicy === "ignore" || errorPolicy === "all";
let writeWithErrors = !(0, internal_1.graphQLResultHasError)(result);
if (!writeWithErrors && ignoreErrors && result.data) {
writeWithErrors = true;
}
return writeWithErrors;
}
//# sourceMappingURL=QueryInfo.cjs.map