UNPKG

@apollo/client

Version:

A fully-featured caching GraphQL client.

355 lines (354 loc) 14.2 kB
import { WeakCache } from "@wry/caches"; import { equal } from "@wry/equality"; import { Trie } from "@wry/trie"; import { wrap } from "optimism"; import { distinctUntilChanged, map, Observable, ReplaySubject, share, shareReplay, tap, timer, } from "rxjs"; import { cacheSizes, canonicalStringify } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import { bindCacheKey, combineLatestBatched, equalByQuery, getApolloCacheMemoryInternals, getFragmentDefinition, getFragmentQueryDocument, mapObservableFragmentMemoized, } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; export class ApolloCache { assumeImmutableResults = false; // Function used to lookup a fragment when a fragment definition is not part // of the GraphQL document. This is useful for caches, such as InMemoryCache, // that register fragments ahead of time so they can be referenced by name. lookupFragment(fragmentName) { return null; } // Transactional API /** * Executes multiple cache operations as a single batch, ensuring that * watchers are only notified once after all operations complete. This is * useful for improving performance when making multiple cache updates, as it * prevents unnecessary re-renders or query refetches between individual * operations. * * The `batch` method supports both optimistic and non-optimistic updates, and * provides fine-grained control over which cache layer receives the updates * and when watchers are notified. * * For usage instructions, see [Interacting with cached data: `cache.batch`](https://www.apollographql.com/docs/react/caching/cache-interaction#using-cachebatch). * * @example * * ```js * cache.batch({ * update(cache) { * cache.writeQuery({ * query: GET_TODOS, * data: { todos: updatedTodos }, * }); * cache.evict({ id: "Todo:123" }); * }, * }); * ``` * * @example * * ```js * // Optimistic update with a custom layer ID * cache.batch({ * optimistic: "add-todo-optimistic", * update(cache) { * cache.modify({ * fields: { * todos(existing = []) { * return [...existing, newTodoRef]; * }, * }, * }); * }, * }); * ``` * * @returns The return value of the `update` function. */ batch(options) { const optimisticId = typeof options.optimistic === "string" ? options.optimistic : options.optimistic === false ? null : void 0; let updateResult; this.performTransaction(() => (updateResult = options.update(this)), optimisticId); return updateResult; } recordOptimisticTransaction(transaction, optimisticId) { this.performTransaction(transaction, optimisticId); } // Optional API // Called once per input document, allowing the cache to make static changes // to the query, such as adding __typename fields. transformDocument(document) { return document; } // Called before each ApolloLink request, allowing the cache to make dynamic // changes to the query, such as filling in missing fragment definitions. transformForLink(document) { return document; } identify(object) { return; } gc() { return []; } modify(options) { return false; } readQuery(options, optimistic = !!options.optimistic) { return this.read({ ...options, rootId: options.id || "ROOT_QUERY", optimistic, }); } fragmentWatches = new Trie(true); /** * Watches the cache store of the fragment according to the options specified * and returns an `Observable`. We can subscribe to this * `Observable` and receive updated results through an * observer when the cache store changes. * * You must pass in a GraphQL document with a single fragment or a document * with multiple fragments that represent what you are reading. If you pass * in a document with multiple fragments then you must also specify a * `fragmentName`. * * @since 3.10.0 * @param options - An object of type `WatchFragmentOptions` that allows * the cache to identify the fragment and optionally specify whether to react * to optimistic updates. */ watchFragment(options) { const { fragment, fragmentName, from } = options; const query = this.getFragmentDoc(fragment, fragmentName); const fromArray = Array.isArray(from) ? from : [from]; const ids = fromArray.map((value) => { // While our TypeScript types do not allow for `undefined` as a valid // `from`, its possible `useFragment` gives us an `undefined` since it // calls` cache.identify` and provides that value to `from`. We are // adding this fix here however to ensure those using plain JavaScript // and using `cache.identify` themselves will avoid seeing the obscure // warning. const id = value == null ? value : this.toCacheId(value); if (__DEV__) { const actualFragmentName = fragmentName || getFragmentDefinition(fragment).name.value; if (id === undefined) { __DEV__ && invariant.warn(113, actualFragmentName); } } return id; }); if (!Array.isArray(from)) { const observable = this.watchSingleFragment(ids[0], query, options); // Unfortunately we forgot to allow for `null` on watchFragment in 4.0 // when `from` is a single record. As such, we need to fallback to {} // when diff.result is null to maintain backwards compatibility. We // should plan to change this in v5. We do however support `null` if // `from` is explicitly `null`. // // NOTE: Using `from` with an array will maintain `null` properly // without the need for a similar fallback since watchFragment with // arrays is new functionality in v4.1. return from === null ? observable : (mapObservableFragmentMemoized(observable, Symbol.for("apollo.transform.individualResult"), (result) => ({ ...result, data: result.data ?? {}, }))); } let currentResult; function toResult(results) { const result = results.reduce((memo, result, idx) => { memo.data.push(result.data); memo.complete &&= result.complete; memo.dataState = memo.complete ? "complete" : "partial"; if (result.missing) { memo.missing ||= {}; memo.missing[idx] = result.missing; } return memo; }, { data: [], dataState: "complete", complete: true, }); if (!equal(currentResult, result)) { currentResult = result; } return currentResult; } if (ids.length === 0) { return emptyArrayObservable; } let subscribed = false; const observables = ids.map((id) => this.watchSingleFragment(id, query, options)); const observable = combineLatestBatched(observables).pipe(map(toResult), tap({ subscribe: () => (subscribed = true), unsubscribe: () => (subscribed = false), }), shareReplay({ bufferSize: 1, refCount: true })); return Object.assign(observable, { getCurrentResult: () => { if (subscribed && currentResult) { return currentResult; } const results = observables.map((observable) => observable.getCurrentResult()); return toResult(results); }, }); } /** * Can be overridden by subclasses to delay calling the provided callback * until after all broadcasts have been completed - e.g. in a cache scenario * where many watchers are notified in parallel. */ onAfterBroadcast = (cb) => cb(); watchSingleFragment(id, fragmentQuery, options) { if (id === null) { return nullObservable; } const { optimistic = true, variables } = options; const cacheKey = [ fragmentQuery, canonicalStringify({ id, optimistic, variables }), ]; const cacheEntry = this.fragmentWatches.lookupArray(cacheKey); if (!cacheEntry.observable) { let subscribed = false; let currentResult; function getNewestResult(diff) { const data = diff.result; if (!currentResult || !equalByQuery(fragmentQuery, { data: currentResult.data }, { data }, options.variables)) { currentResult = { data, dataState: diff.complete ? "complete" : "partial", complete: diff.complete, }; if (diff.missing) { currentResult.missing = diff.missing.missing; } } return currentResult; } const observable = new Observable((observer) => { subscribed = true; const cleanup = this.watch({ variables, returnPartialData: true, id, query: fragmentQuery, optimistic, immediate: true, callback: (diff) => { observable.dirty = true; this.onAfterBroadcast(() => { observer.next(getNewestResult(diff)); observable.dirty = false; }); }, }); return () => { subscribed = false; cleanup(); this.fragmentWatches.removeArray(cacheKey); }; }).pipe(distinctUntilChanged(), share({ connector: () => new ReplaySubject(1), // debounce so a synchronous unsubscribe+resubscribe doesn't tear down the watch and create a new one resetOnRefCountZero: () => timer(0), })); cacheEntry.observable = Object.assign(observable, { dirty: false, getCurrentResult: () => { if (subscribed && currentResult) { return currentResult; } return getNewestResult(this.diff({ id, query: fragmentQuery, returnPartialData: true, optimistic, variables, })); }, }); } return cacheEntry.observable; } // Make sure we compute the same (===) fragment query document every // time we receive the same fragment in readFragment. getFragmentDoc = wrap(getFragmentQueryDocument, { max: cacheSizes["cache.fragmentQueryDocuments"] || 1000 /* defaultCacheSizes["cache.fragmentQueryDocuments"] */, cache: WeakCache, makeCacheKey: bindCacheKey(this), }); readFragment(options, optimistic = !!options.optimistic) { const id = options.from !== undefined ? this.toCacheId(options.from) : options.id; return this.read({ ...options, query: this.getFragmentDoc(options.fragment, options.fragmentName), rootId: id, optimistic, }); } writeQuery({ id, data, ...options }) { return this.write(Object.assign(options, { dataId: id || "ROOT_QUERY", result: data, })); } writeFragment({ data, fragment, fragmentName, ...options }) { const id = options.from !== undefined ? this.toCacheId(options.from) : options.id; return this.write(Object.assign(options, { query: this.getFragmentDoc(fragment, fragmentName), dataId: id, result: data, })); } updateQuery(options, update) { return this.batch({ update(cache) { const value = cache.readQuery(options); const data = update(value); if (data === void 0 || data === null) return value; cache.writeQuery({ ...options, data }); return data; }, }); } updateFragment(options, update) { return this.batch({ update(cache) { const value = cache.readFragment(options); const data = update(value); if (data === void 0 || data === null) return value; cache.writeFragment({ ...options, data }); return data; }, }); } toCacheId(from) { return typeof from === "string" ? from : this.identify(from); } } if (__DEV__) { ApolloCache.prototype.getMemoryInternals = getApolloCacheMemoryInternals; } const nullResult = Object.freeze({ data: null, dataState: "complete", complete: true, }); const nullObservable = Object.assign(new Observable((observer) => { observer.next(nullResult); }), { dirty: false, getCurrentResult: () => nullResult }); const emptyArrayResult = Object.freeze({ data: [], dataState: "complete", complete: true, }); const emptyArrayObservable = Object.assign(new Observable((observer) => { observer.next(emptyArrayResult); }), { getCurrentResult: () => emptyArrayResult }); //# sourceMappingURL=cache.js.map