UNPKG

@apollo/client

Version:

A fully-featured caching GraphQL client.

1,102 lines (1,100 loc) 59.9 kB
import { equal } from "@wry/equality"; import { BehaviorSubject, Observable, share, Subject, tap } from "rxjs"; import { isNetworkRequestInFlight } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import { compact, equalByQuery, filterMap, getOperationDefinition, getOperationName, getQueryDefinition, preventUnhandledRejection, toQueryResult, } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; import { NetworkStatus } from "./networkStatus.js"; const { assign, hasOwnProperty } = Object; const uninitialized = { loading: true, networkStatus: NetworkStatus.loading, data: undefined, dataState: "empty", partial: true, }; const empty = { loading: false, networkStatus: NetworkStatus.ready, data: undefined, dataState: "empty", partial: true, }; export class ObservableQuery { options; queryName; /** * @internal will be read and written from `QueryInfo` * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ _lastWrite; // The `query` computed property will always reflect the document transformed // by the last run query. `this.options.query` will always reflect the raw // untransformed query to ensure document transforms with runtime conditionals // are run on the original document. get query() { return this.lastQuery; } /** * An object containing the variables that were provided for the query. */ get variables() { return this.options.variables; } unsubscribeFromCache; input; subject; isTornDown; queryManager; subscriptions = new Set(); /** * If an `ObservableQuery` is created with a `network-only` fetch policy, * it should actually start receiving cache updates, but not before it has * received the first result from the network. */ waitForNetworkResult; lastQuery; linkSubscription; pollingInfo; get networkStatus() { return this.subject.getValue().result.networkStatus; } constructor({ queryManager, options, transformedQuery = queryManager.transform(options.query), }) { this.queryManager = queryManager; // active state this.waitForNetworkResult = options.fetchPolicy === "network-only"; this.isTornDown = false; this.subscribeToMore = this.subscribeToMore.bind(this); this.maskResult = this.maskResult.bind(this); const { watchQuery: { fetchPolicy: defaultFetchPolicy = "cache-first" } = {}, } = queryManager.defaultOptions; const { fetchPolicy = defaultFetchPolicy, // Make sure we don't store "standby" as the initialFetchPolicy. initialFetchPolicy = fetchPolicy === "standby" ? defaultFetchPolicy : (fetchPolicy), } = options; this.lastQuery = transformedQuery; this.options = { ...options, // Remember the initial options.fetchPolicy so we can revert back to this // policy when variables change. This information can also be specified // (or overridden) by providing options.initialFetchPolicy explicitly. initialFetchPolicy, // This ensures this.options.fetchPolicy always has a string value, in // case options.fetchPolicy was not provided. fetchPolicy, variables: this.getVariablesWithDefaults(options.variables), }; this.initializeObservablesQueue(); this["@@observable"] = () => this; if (Symbol.observable) { this[Symbol.observable] = () => this; } const opDef = getOperationDefinition(this.query); this.queryName = opDef && opDef.name && opDef.name.value; } initializeObservablesQueue() { this.subject = new BehaviorSubject({ query: this.query, variables: this.variables, result: uninitialized, meta: {}, }); const observable = this.subject.pipe(tap({ subscribe: () => { if (!this.subject.observed) { this.reobserve(); // TODO: See if we can rework updatePolling to better handle this. // reobserve calls updatePolling but this `subscribe` callback is // called before the subject is subscribed to so `updatePolling` // can't accurately detect if there is an active subscription. // Calling it again here ensures that it can detect if it can poll setTimeout(() => this.updatePolling()); } }, unsubscribe: () => { if (!this.subject.observed) { this.tearDownQuery(); } }, }), filterMap(({ query, variables, result: current, meta }, context) => { const { shouldEmit } = meta; if (current === uninitialized) { // reset internal state after `ObservableQuery.reset()` context.previous = undefined; context.previousVariables = undefined; } if (this.options.fetchPolicy === "standby" || shouldEmit === 2 /* EmitBehavior.never */) return; if (shouldEmit === 1 /* EmitBehavior.force */) return emit(); const { previous, previousVariables } = context; if (previous) { const documentInfo = this.queryManager.getDocumentInfo(query); const dataMasking = this.queryManager.dataMasking; const maskedQuery = dataMasking ? documentInfo.nonReactiveQuery : query; const resultIsEqual = dataMasking || documentInfo.hasNonreactiveDirective ? equalByQuery(maskedQuery, previous, current, variables) : equal(previous, current); if (resultIsEqual && equal(previousVariables, variables)) { return; } } if (shouldEmit === 3 /* EmitBehavior.networkStatusChange */ && (!this.options.notifyOnNetworkStatusChange || equal(previous, current))) { return; } return emit(); function emit() { context.previous = current; context.previousVariables = variables; return current; } }, () => ({}))); this.pipe = observable.pipe.bind(observable); this.subscribe = observable.subscribe.bind(observable); this.input = new Subject(); // we want to feed many streams into `this.subject`, but none of them should // be able to close `this.input` this.input.complete = () => { }; this.input.pipe(this.operator).subscribe(this.subject); } // We can't use Observable['subscribe'] here as the type as it conflicts with // the ability to infer T from Subscribable<T>. This limits the surface area // to the non-deprecated signature which works properly with type inference. /** * Subscribes to the `ObservableQuery`. * @param observerOrNext - Either an RxJS `Observer` with some or all callback methods, * or the `next` handler that is called for each value emitted from the subscribed Observable. * @returns A subscription reference to the registered handlers. */ subscribe; /** * Used to stitch together functional operators into a chain. * * @example * * ```ts * import { filter, map } from 'rxjs'; * * observableQuery * .pipe( * filter(...), * map(...), * ) * .subscribe(x => console.log(x)); * ``` * * @returns The Observable result of all the operators having been called * in the order they were passed in. */ pipe; [Symbol.observable]; ["@@observable"]; /** * @internal * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ getCacheDiff({ optimistic = true } = {}) { return this.queryManager.cache.diff({ query: this.query, variables: this.variables, returnPartialData: true, optimistic, }); } getInitialResult(initialFetchPolicy) { const fetchPolicy = this.queryManager.prioritizeCacheValues ? "cache-first" : initialFetchPolicy || this.options.fetchPolicy; const cacheResult = () => { const diff = this.getCacheDiff(); // TODO: queryInfo.getDiff should handle this since cache.diff returns a // null when returnPartialData is false const data = this.options.returnPartialData || diff.complete ? diff.result ?? undefined : undefined; return this.maskResult({ data, dataState: diff.complete ? "complete" : data === undefined ? "empty" : "partial", loading: !diff.complete, networkStatus: diff.complete ? NetworkStatus.ready : NetworkStatus.loading, partial: !diff.complete, }); }; switch (fetchPolicy) { case "cache-only": { return { ...cacheResult(), loading: false, networkStatus: NetworkStatus.ready, }; } case "cache-first": return cacheResult(); case "cache-and-network": return { ...cacheResult(), loading: true, networkStatus: NetworkStatus.loading, }; case "standby": return empty; default: return uninitialized; } } resubscribeCache() { const { variables, fetchPolicy } = this.options; const query = this.query; const shouldUnsubscribe = fetchPolicy === "standby" || fetchPolicy === "no-cache" || this.waitForNetworkResult; const shouldResubscribe = !isEqualQuery({ query, variables }, this.unsubscribeFromCache) && !this.waitForNetworkResult; if (shouldUnsubscribe || shouldResubscribe) { this.unsubscribeFromCache?.(); } if (shouldUnsubscribe || !shouldResubscribe) { return; } const watch = { query, variables, optimistic: true, watcher: this, callback: (diff) => { const info = this.queryManager.getDocumentInfo(query); if (info.hasClientExports || info.hasForcedResolvers) { // If this is not set to something different than `diff`, we will // not be notified about future cache changes with an equal `diff`. // That would be the case if we are working with client-only fields // that are forced or with `exports` fields that might change, causing // local resovlers to return a new result. // This is based on an implementation detail of `InMemoryCache`, which // is not optimal - but the only alternative to this would be to // resubscribe to the cache asynchonouly, which would bear the risk of // missing further synchronous updates. watch.lastDiff = undefined; } if (watch.lastOwnDiff === diff) { // skip cache updates that were caused by our own writes return; } const { result: previousResult } = this.subject.getValue(); if (!diff.complete && // If we are trying to deliver an incomplete cache result, we avoid // reporting it if the query has errored, otherwise we let the broadcast try // and repair the partial result by refetching the query. This check avoids // a situation where a query that errors and another succeeds with // overlapping data does not report the partial data result to the errored // query. // // See https://github.com/apollographql/apollo-client/issues/11400 for more // information on this issue. (previousResult.error || // Prevent to schedule a notify directly after the `ObservableQuery` // has been `reset` (which will set the `previousResult` to `uninitialized` or `empty`) // as in those cases, `resetCache` will manually call `refetch` with more intentional timing. previousResult === uninitialized || previousResult === empty)) { return; } if (!equal(previousResult.data, diff.result)) { this.scheduleNotify(); } }, }; const cancelWatch = this.queryManager.cache.watch(watch); this.unsubscribeFromCache = Object.assign(() => { this.unsubscribeFromCache = undefined; cancelWatch(); }, { query, variables }); } stableLastResult; getCurrentResult() { const { result: current } = this.subject.getValue(); let value = ( // if the `current` result is in an error state, we will always return that // error state, even if we have no observers (current.networkStatus === NetworkStatus.error || // if we have observers, we are watching the cache and // this.subject.getValue() will always be up to date this.hasObservers() || // if we are using a `no-cache` fetch policy in which case this // `ObservableQuery` cannot have been updated from the outside - in // that case, we prefer to keep the current value this.options.fetchPolicy === "no-cache")) ? current // otherwise, the `current` value might be outdated due to missed // external updates - calculate it again : this.getInitialResult(); if (value === uninitialized) { value = this.getInitialResult(); } if (!equal(this.stableLastResult, value)) { this.stableLastResult = value; } return this.stableLastResult; } /** * Update the variables of this observable query, and fetch the new results. * This method should be preferred over `setVariables` in most use cases. * * Returns a `ResultPromise` with an additional `.retain()` method. Calling * `.retain()` keeps the network operation running even if the `ObservableQuery` * no longer requires the result. * * Note: `refetch()` guarantees that a value will be emitted from the * observable, even if the result is deep equal to the previous value. * * @param variables - The new set of variables. If there are missing variables, * the previous values of those variables will be used. */ refetch(variables) { const { fetchPolicy } = this.options; const reobserveOptions = { // Always disable polling for refetches. pollInterval: 0, }; // Unless the provided fetchPolicy always consults the network // (no-cache, network-only, or cache-and-network), override it with // network-only to force the refetch for this fetchQuery call. if (fetchPolicy === "no-cache") { reobserveOptions.fetchPolicy = "no-cache"; } else { reobserveOptions.fetchPolicy = "network-only"; } if (__DEV__ && variables && hasOwnProperty.call(variables, "variables")) { const queryDef = getQueryDefinition(this.query); const vars = queryDef.variableDefinitions; if (!vars || !vars.some((v) => v.variable.name.value === "variables")) { __DEV__ && invariant.warn(77, variables, queryDef.name?.value || queryDef); } } if (variables && !equal(this.variables, variables)) { // Update the existing options with new variables reobserveOptions.variables = this.options.variables = this.getVariablesWithDefaults({ ...this.variables, ...variables }); } this._lastWrite = undefined; return this._reobserve(reobserveOptions, { newNetworkStatus: NetworkStatus.refetch, }); } fetchMore({ query, variables, context, errorPolicy, updateQuery, }) { invariant( this.options.fetchPolicy !== "cache-only", 78, getOperationName(this.query, "(anonymous)") ); const combinedOptions = { ...compact(this.options, { errorPolicy: "none" }, { query, context, errorPolicy, }), variables: (query ? variables : ({ ...this.variables, ...variables, })), // The fetchMore request goes immediately to the network and does // not automatically write its result to the cache (hence no-cache // instead of network-only), because we allow the caller of // fetchMore to provide an updateQuery callback that determines how // the data gets written to the cache. fetchPolicy: "no-cache", notifyOnNetworkStatusChange: this.options.notifyOnNetworkStatusChange, }; combinedOptions.query = this.transformDocument(combinedOptions.query); // If a temporary query is passed to `fetchMore`, we don't want to store // it as the last query result since it may be an optimized query for // pagination. We will however run the transforms on the original document // as well as the document passed in `fetchMoreOptions` to ensure the cache // uses the most up-to-date document which may rely on runtime conditionals. this.lastQuery = query ? this.transformDocument(this.options.query) : combinedOptions.query; let wasUpdated = false; const isCached = this.options.fetchPolicy !== "no-cache"; if (!isCached) { invariant(updateQuery, 79); } const { finalize, pushNotification } = this.pushOperation(NetworkStatus.fetchMore); pushNotification({ source: "newNetworkStatus", kind: "N", value: {}, }, { shouldEmit: 3 /* EmitBehavior.networkStatusChange */ }); return this.queryManager .fetchQuery(combinedOptions, NetworkStatus.fetchMore) .then((fetchMoreResult) => { // disable the `fetchMore` override that is currently active // the next updates caused by this should not be `fetchMore` anymore, // but `ready` or whatever other calculated loading state is currently // appropriate finalize(); if (isCached) { // Performing this cache update inside a cache.batch transaction ensures // any affected cache.watch watchers are notified at most once about any // updates. Most watchers will be using the QueryInfo class, which // responds to notifications by calling reobserveCacheFirst to deliver // fetchMore cache results back to this ObservableQuery. this.queryManager.cache.batch({ update: (cache) => { if (updateQuery) { cache.updateQuery({ query: this.query, variables: this.variables, returnPartialData: true, optimistic: false, }, (previous) => updateQuery(previous, { fetchMoreResult: fetchMoreResult.data, variables: combinedOptions.variables, })); } else { // If we're using a field policy instead of updateQuery, the only // thing we need to do is write the new data to the cache using // combinedOptions.variables (instead of this.variables, which is // what this.updateQuery uses, because it works by abusing the // original field value, keyed by the original variables). cache.writeQuery({ query: combinedOptions.query, variables: combinedOptions.variables, data: fetchMoreResult.data, }); } }, onWatchUpdated: (watch) => { if (watch.watcher === this) { wasUpdated = true; } }, }); } else { // There is a possibility `lastResult` may not be set when // `fetchMore` is called which would cause this to crash. This should // only happen if we haven't previously reported a result. We don't // quite know what the right behavior should be here since this block // of code runs after the fetch result has executed on the network. // We plan to let it crash in the meantime. // // If we get bug reports due to the `data` property access on // undefined, this should give us a real-world scenario that we can // use to test against and determine the right behavior. If we do end // up changing this behavior, this may require, for example, an // adjustment to the types on `updateQuery` since that function // expects that the first argument always contains previous result // data, but not `undefined`. const lastResult = this.getCurrentResult(); const data = updateQuery(lastResult.data, { fetchMoreResult: fetchMoreResult.data, variables: combinedOptions.variables, }); // was reportResult pushNotification({ kind: "N", value: { ...lastResult, networkStatus: NetworkStatus.ready, // will be overwritten anyways, just here for types sake loading: false, data: data, dataState: lastResult.dataState === "streaming" ? "streaming" : "complete", }, source: "network", }); } return this.maskResult(fetchMoreResult); }) .finally(() => { // call `finalize` a second time in case the `.then` case above was not reached finalize(); // In case the cache writes above did not generate a broadcast // notification (which would have been intercepted by onWatchUpdated), // likely because the written data were the same as what was already in // the cache, we still want fetchMore to deliver its final loading:false // result with the unchanged data. if (isCached && !wasUpdated) { pushNotification({ kind: "N", source: "newNetworkStatus", value: {}, }, { shouldEmit: 1 /* EmitBehavior.force */ }); } }); } // XXX the subscription variables are separate from the query variables. // if you want to update subscription variables, right now you have to do that separately, // and you can only do it by stopping the subscription and then subscribing again with new variables. /** * A function that enables you to execute a [subscription](https://www.apollographql.com/docs/react/data/subscriptions/), usually to subscribe to specific fields that were included in the query. * * This function returns _another_ function that you can call to terminate the subscription. */ subscribeToMore(options) { const subscription = this.queryManager .startGraphQLSubscription({ query: options.document, variables: options.variables, context: options.context, }) .subscribe({ next: (subscriptionData) => { const { updateQuery, onError } = options; const { error } = subscriptionData; if (error) { if (onError) { onError(error); } else { invariant.error(80, error); } return; } if (updateQuery) { this.updateQuery((previous, updateOptions) => updateQuery(previous, { subscriptionData: subscriptionData, ...updateOptions, })); } }, }); this.subscriptions.add(subscription); return () => { if (this.subscriptions.delete(subscription)) { subscription.unsubscribe(); } }; } /** * @internal * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ applyOptions(newOptions) { const mergedOptions = compact(this.options, newOptions || {}); assign(this.options, mergedOptions); this.updatePolling(); } /** * Update the variables of this observable query, and fetch the new results * if they've changed. Most users should prefer `refetch` instead of * `setVariables` in order to to be properly notified of results even when * they come from the cache. * * Note: `setVariables()` guarantees that a value will be emitted from the * observable, even if the result is deeply equal to the previous value. * * Note: the promise will resolve with the last emitted result * when either the variables match the current variables or there * are no subscribers to the query. * * @param variables - The new set of variables. If there are missing variables, * the previous values of those variables will be used. */ async setVariables(variables) { variables = this.getVariablesWithDefaults(variables); if (equal(this.variables, variables)) { // If we have no observers, then we don't actually want to make a network // request. As soon as someone observes the query, the request will kick // off. For now, we just store any changes. (See #1077) return toQueryResult(this.getCurrentResult()); } this.options.variables = variables; // See comment above if (!this.hasObservers()) { return toQueryResult(this.getCurrentResult()); } return this._reobserve({ // Reset options.fetchPolicy to its original value. fetchPolicy: this.options.initialFetchPolicy, variables, }, { newNetworkStatus: NetworkStatus.setVariables }); } /** * A function that enables you to update the query's cached result without executing a followup GraphQL operation. * * See [using updateQuery and updateFragment](https://www.apollographql.com/docs/react/caching/cache-interaction/#using-updatequery-and-updatefragment) for additional information. */ updateQuery(mapFn) { const { queryManager } = this; const { result, complete } = this.getCacheDiff({ optimistic: false }); const newResult = mapFn(result, { variables: this.variables, complete: !!complete, previousData: result, }); if (newResult) { queryManager.cache.writeQuery({ query: this.options.query, data: newResult, variables: this.variables, }); queryManager.broadcastQueries(); } } /** * A function that instructs the query to begin re-executing at a specified interval (in milliseconds). */ startPolling(pollInterval) { this.options.pollInterval = pollInterval; this.updatePolling(); } /** * A function that instructs the query to stop polling after a previous call to `startPolling`. */ stopPolling() { this.options.pollInterval = 0; this.updatePolling(); } // Update options.fetchPolicy according to options.nextFetchPolicy. applyNextFetchPolicy(reason, // It's possible to use this method to apply options.nextFetchPolicy to // options.fetchPolicy even if options !== this.options, though that happens // most often when the options are temporary, used for only one request and // then thrown away, so nextFetchPolicy may not end up mattering. options) { if (options.nextFetchPolicy) { const { fetchPolicy = "cache-first", initialFetchPolicy = fetchPolicy } = options; if (fetchPolicy === "standby") { // Do nothing, leaving options.fetchPolicy unchanged. } else if (typeof options.nextFetchPolicy === "function") { // When someone chooses "cache-and-network" or "network-only" as their // initial FetchPolicy, they often do not want future cache updates to // trigger unconditional network requests, which is what repeatedly // applying the "cache-and-network" or "network-only" policies would // seem to imply. Instead, when the cache reports an update after the // initial network request, it may be desirable for subsequent network // requests to be triggered only if the cache result is incomplete. To // that end, the options.nextFetchPolicy option provides an easy way to // update options.fetchPolicy after the initial network request, without // having to call observableQuery.reobserve. options.fetchPolicy = options.nextFetchPolicy.call(options, fetchPolicy, { reason, options, observable: this, initialFetchPolicy }); } else if (reason === "variables-changed") { options.fetchPolicy = initialFetchPolicy; } else { options.fetchPolicy = options.nextFetchPolicy; } } return options.fetchPolicy; } fetch(options, networkStatus, fetchQuery, operator) { // TODO Make sure we update the networkStatus (and infer fetchVariables) // before actually committing to the fetch. const initialFetchPolicy = this.options.fetchPolicy; options.context ??= {}; let synchronouslyEmitted = false; const onCacheHit = () => { synchronouslyEmitted = true; }; const fetchQueryOperator = // we cannot use `tap` here, since it allows only for a "before subscription" // hook with `subscribe` and we care for "directly before and after subscription" (source) => new Observable((subscriber) => { try { return source.subscribe({ next(value) { synchronouslyEmitted = true; subscriber.next(value); }, error: (error) => subscriber.error(error), complete: () => subscriber.complete(), }); } finally { if (!synchronouslyEmitted) { operation.override = networkStatus; this.input.next({ kind: "N", source: "newNetworkStatus", value: { resetError: true, }, query, variables, meta: { shouldEmit: 3 /* EmitBehavior.networkStatusChange */, /* * The moment this notification is emitted, `nextFetchPolicy` * might already have switched from a `network-only` to a * `cache-something` policy, so we want to ensure that the * loading state emit doesn't accidentally read from the cache * in those cases. */ fetchPolicy: initialFetchPolicy, }, }); } } }); let { observable, fromLink } = this.queryManager.fetchObservableWithInfo(options, { networkStatus, query: fetchQuery, onCacheHit, fetchQueryOperator, observableQuery: this, }); // track query and variables from the start of the operation const { query, variables } = this; const operation = { abort: () => { subscription.unsubscribe(); }, query, variables, }; this.activeOperations.add(operation); let forceFirstValueEmit = networkStatus == NetworkStatus.refetch || networkStatus == NetworkStatus.setVariables; observable = observable.pipe(operator, share()); const subscription = observable .pipe(tap({ next: (notification) => { if (notification.source === "newNetworkStatus" || (notification.kind === "N" && notification.value.loading)) { operation.override = networkStatus; } else { delete operation.override; } }, finalize: () => this.activeOperations.delete(operation), })) .subscribe({ next: (value) => { const meta = {}; if (forceFirstValueEmit && value.kind === "N" && "loading" in value.value && !value.value.loading) { forceFirstValueEmit = false; meta.shouldEmit = 1 /* EmitBehavior.force */; } this.input.next({ ...value, query, variables, meta }); }, }); return { fromLink, subscription, observable }; } // Turns polling on or off based on this.options.pollInterval. didWarnCacheOnlyPolling = false; updatePolling() { // Avoid polling in SSR mode if (this.queryManager.ssrMode) { return; } const { pollingInfo, options: { fetchPolicy, pollInterval }, } = this; if (!pollInterval || !this.hasObservers() || fetchPolicy === "cache-only") { if (__DEV__) { if (!this.didWarnCacheOnlyPolling && pollInterval && fetchPolicy === "cache-only") { __DEV__ && invariant.warn(81, getOperationName(this.query, "(anonymous)")); this.didWarnCacheOnlyPolling = true; } } this.cancelPolling(); return; } if (pollingInfo?.interval === pollInterval) { return; } const info = pollingInfo || (this.pollingInfo = {}); info.interval = pollInterval; const maybeFetch = () => { if (this.pollingInfo) { if (!isNetworkRequestInFlight(this.networkStatus) && !this.options.skipPollAttempt?.()) { this._reobserve({ // Most fetchPolicy options don't make sense to use in a polling context, as // users wouldn't want to be polling the cache directly. However, network-only and // no-cache are both useful for when the user wants to control whether or not the // polled results are written to the cache. fetchPolicy: this.options.initialFetchPolicy === "no-cache" ? "no-cache" : "network-only", }, { newNetworkStatus: NetworkStatus.poll, }).then(poll, poll); } else { poll(); } } }; const poll = () => { const info = this.pollingInfo; if (info) { clearTimeout(info.timeout); info.timeout = setTimeout(maybeFetch, info.interval); } }; poll(); } // This differs from stopPolling in that it does not set pollInterval to 0 cancelPolling() { if (this.pollingInfo) { clearTimeout(this.pollingInfo.timeout); delete this.pollingInfo; } } /** * Reevaluate the query, optionally against new options. New options will be * merged with the current options when given. * * Note: `variables` can be reset back to their defaults (typically empty) by calling `reobserve` with * `variables: undefined`. */ reobserve(newOptions) { return this._reobserve(newOptions); } _reobserve(newOptions, internalOptions) { this.isTornDown = false; let { newNetworkStatus } = internalOptions || {}; this.queryManager.obsQueries.add(this); const useDisposableObservable = // Refetching uses a disposable Observable to allow refetches using different // options, without permanently altering the options of the // original ObservableQuery. newNetworkStatus === NetworkStatus.refetch || // Polling uses a disposable Observable so the polling options (which force // fetchPolicy to be "network-only" or "no-cache") won't override the original options. newNetworkStatus === NetworkStatus.poll; // Save the old variables, since Object.assign may modify them below. const oldVariables = this.variables; const oldFetchPolicy = this.options.fetchPolicy; const mergedOptions = compact(this.options, newOptions || {}); const options = useDisposableObservable ? // Disposable Observable fetches receive a shallow copy of this.options // (merged with newOptions), leaving this.options unmodified. mergedOptions : assign(this.options, mergedOptions); // Don't update options.query with the transformed query to avoid // overwriting this.options.query when we aren't using a disposable concast. // We want to ensure we can re-run the custom document transforms the next // time a request is made against the original query. const query = this.transformDocument(options.query); this.lastQuery = query; // Reevaluate variables to allow resetting variables with variables: undefined, // otherwise `compact` will ignore the `variables` key in `newOptions`. We // do this after we run the query transform to ensure we get default // variables from the transformed query. // // Note: updating options.variables may mutate this.options.variables // in the case of a non-disposable query. This is intentional. if (newOptions && "variables" in newOptions) { options.variables = this.getVariablesWithDefaults(newOptions.variables); } if (!useDisposableObservable) { // We can skip calling updatePolling if we're not changing this.options. this.updatePolling(); // Reset options.fetchPolicy to its original value when variables change, // unless a new fetchPolicy was provided by newOptions. if (newOptions && newOptions.variables && !equal(newOptions.variables, oldVariables) && // Don't mess with the fetchPolicy if it's currently "standby". options.fetchPolicy !== "standby" && // If we're changing the fetchPolicy anyway, don't try to change it here // using applyNextFetchPolicy. The explicit options.fetchPolicy wins. (options.fetchPolicy === oldFetchPolicy || // A `nextFetchPolicy` function has even higher priority, though, // so in that case `applyNextFetchPolicy` must be called. typeof options.nextFetchPolicy === "function")) { // This might mutate options.fetchPolicy this.applyNextFetchPolicy("variables-changed", options); if (newNetworkStatus === void 0) { newNetworkStatus = NetworkStatus.setVariables; } } } const oldNetworkStatus = this.networkStatus; if (!newNetworkStatus) { newNetworkStatus = NetworkStatus.loading; if (oldNetworkStatus !== NetworkStatus.loading && newOptions?.variables && !equal(newOptions.variables, oldVariables)) { newNetworkStatus = NetworkStatus.setVariables; } // QueryManager does not emit any values for standby fetch policies so we // want ensure that the networkStatus remains ready. if (options.fetchPolicy === "standby") { newNetworkStatus = NetworkStatus.ready; } } if (options.fetchPolicy === "standby") { this.cancelPolling(); } this.resubscribeCache(); const { promise, operator: promiseOperator } = getTrackingOperatorPromise((value) => { switch (value.kind) { case "E": throw value.error; case "N": if (value.source !== "newNetworkStatus" && !value.value.loading) return value.value; } }, // This default value should only be used when using a `fetchPolicy` of // `standby` since that fetch policy completes without emitting a // result. Since we are converting this to a QueryResult type, we // omit the extra fields from ApolloQueryResult in the default value. options.fetchPolicy === "standby" ? { data: undefined } : undefined); const { subscription, observable, fromLink } = this.fetch(options, newNetworkStatus, query, promiseOperator); if (!useDisposableObservable && (fromLink || !this.linkSubscription)) { if (this.linkSubscription) { this.linkSubscription.unsubscribe(); } this.linkSubscription = subscription; } const ret = Object.assign(preventUnhandledRejection(promise .then((result) => toQueryResult(this.maskResult(result))) .finally(() => { if (!this.hasObservers() && this.activeOperations.size === 0) { // If `reobserve` was called on a query without any obervers, // the teardown logic would never be called, so we need to // call it here to ensure the query is properly torn down. this.tearDownQuery(); } })), { retain: () => { const subscription = observable.subscribe({}); const unsubscribe = () => subscription.unsubscribe(); promise.then(unsubscribe, unsubscribe); return ret; }, }); return ret; } hasObservers() { return this.subject.observed; } /** * Tears down the `ObservableQuery` and stops all active operations by sending a `complete` notification. */ stop() { this.subject.complete(); this.initializeObservablesQueue(); this.tearDownQuery(); } tearDownQuery() { if (this.isTornDown) return; this.resetNotifications(); this.unsubscribeFromCache?.(); if (this.linkSubscription) { this.linkSubscription.unsubscribe(); delete this.linkSubscription; } this.stopPolling(); // stop all active GraphQL subscriptions this.subscriptions.forEach((sub) => sub.unsubscribe()); this.subscriptions.clear(); this.queryManager.obsQueries.delete(this); this.isTornDown = true; this.abortActiveOperations(); this._lastWrite = undefined; } transformDocument(document) { return this.queryManager.transform(document); } maskResult(result) { const masked = this.queryManager.maskOperation({ document: this.query, data: result.data, fetchPolicy: this.options.fetchPolicy, cause: this, }); // Maintain object identity as much as possible return masked === result.data ? result : { ...result, data: masked }; } dirty = false; notifyTimeout; /** * @internal * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ resetNotifications() { if (this.notifyTimeout) { clearTimeout(this.notifyTimeout); this.notifyTimeout = void 0; } this.dirty = false; } /** * @internal * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ scheduleNotify() { if (this.dirty) return; this.dirty = true; if (!this.notifyTimeout) { this.notifyTimeout = setTimeout(() => this.notify(true), 0); } } /** * @internal * * @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time. */ notify(scheduled = false) { if (!scheduled) { // For queries with client exports or forced resolvers, we don't want to // synchronously reobserve the cache on broadcast, // but actually wait for the `scheduleNotify` timeout triggered by the // `cache.watch` callback from `resubscribeCache`. const info = this.queryManager.getDocumentInfo(this.query); if (info.hasClientExports || info.hasForcedResolvers) { return; } } const { dirty } = this; this.resetNotifications(); if (dirty && (this.options.fetchPolicy == "cache-only" || this.options.fetchPolicy == "cache-and-network" || !this.activeOperations.size)) { const diff = this.getCacheDiff(); if ( // `fromOptimisticTransaction` is not avaiable through the `cache.diff` // code path, so we need to check it this way equal(diff.result, this.getCacheDiff({ optimistic: false }).result)) { //If this diff did not come from an optimistic transaction // make the ObservableQuery "reobserve" the latest data // using a temporary fetch policy of "cache-first", so complete cache // results have a chance to be delivered without triggering additional // network requests, even when options.fetchPolicy is "network-only" // or "cache-and-network". All other fetch policies are preserved by // this method, and are handled by calling oq.reobserve(). If this // reobservation is spurious, distinctUntilChanged still has a // chance to catch it before delivery to ObservableQuery subscribers. this.reobserveCacheFirst(); } else { // If this diff came from an optimistic transaction, deliver the // current cache data to the ObservableQuery, but don't perform a // reobservation, since oq.reobserveCacheFirst might make a network // request, and we never want to trigger network requests in the // middle of optimistic updates. this.input.next({ kind: "N", value: { data: diff.result, dataState: diff.complete ? "complete" : diff.result ? "partial" : "empty", networkStatus: NetworkStatus.ready, loading: false,