From 82c68c838978c87e95bbab8ef9e3194a9d844a77 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 6 Apr 2021 16:27:59 -0400 Subject: [PATCH] Improve options API of client.refetchQueries method. This is obviously a breaking change for client.refetchQueries, but that method was introduced in PR #7431, targeting the release-3.4 branch. Since Apollo Client v3.4 is still in beta, we still have room to rework the signature of the client.refetchQueries method (introduced in #7431 and released in @apollo/client@3.4.0-beta.3), not only adding significant new functionality like options.updateCache and options.onQueryUpdated, but also leaving room to add functionality more easily in the future, without breaking backwards compatibility, since client.refetchQueries takes named options and returns an object of named results, so adding new options or returning new results never needs to be a breaking change. --- src/__tests__/client.ts | 4 +- src/core/ApolloClient.ts | 29 ++- src/core/QueryManager.ts | 316 +++++++++++++++-------- src/core/__tests__/QueryManager/index.ts | 4 +- src/core/watchQueryOptions.ts | 8 + 5 files changed, 250 insertions(+), 111 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index af13183cef1..6f0f58ecb61 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2436,7 +2436,9 @@ describe('client', () => { // @ts-ignore const spy = jest.spyOn(client.queryManager, 'refetchQueries'); - await client.refetchQueries(['Author1']); + await client.refetchQueries({ + include: ['Author1'], + }); expect(spy).toHaveBeenCalled(); }); diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index a87b4e30395..26bc4013a6e 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -23,7 +23,7 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - RefetchQueryDescription, + RefetchQueriesOptions, } from './watchQueryOptions'; import { @@ -536,9 +536,30 @@ export class ApolloClient implements DataProxy { * Takes optional parameter `includeStandby` which will include queries in standby-mode when refetching. */ public refetchQueries( - queries: RefetchQueryDescription, - ): Promise[]> { - return Promise.all(this.queryManager.refetchQueries(queries)); + options: Pick< + RefetchQueriesOptions>, + | "updateCache" + | "include" + | "optimistic" + | "onQueryUpdated" + >, + ): Promise<{ + queries: ObservableQuery[]; + results: Map, ApolloQueryResult>; + }> { + const results = this.queryManager.refetchQueries(options); + const queries: ObservableQuery[] = []; + const values: any[] = []; + + results.forEach((value, obsQuery) => { + queries.push(obsQuery); + values.push(value); + }); + + return Promise.all(values).then(values => { + values.forEach((value, i) => results.set(queries[i], value)); + return { queries, results }; + }); } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 48d122e8d1c..59da8d3ec66 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -29,6 +29,7 @@ import { WatchQueryFetchPolicy, ErrorPolicy, RefetchQueryDescription, + RefetchQueriesOptions, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -194,8 +195,6 @@ export class QueryManager { const self = this; return new Promise((resolve, reject) => { - let storeResult: FetchResult | null; - return asyncMap( self.getObservableFromLink( mutation, @@ -219,37 +218,75 @@ export class QueryManager { mutationStoreValue.error = null; } - storeResult = result; - - if (fetchPolicy !== 'no-cache') { - try { - // Returning the result of markMutationResult here makes the - // mutation await any Promise that markMutationResult returns, - // since we are returning this Promise from the asyncMap mapping - // function. - return self.markMutationResult({ - mutationId, - result, - document: mutation, - variables, - removeOptimistic: !!optimisticResponse, - errorPolicy, - context, - updateQueries, - update: updateWithProxyFn, - onQueryUpdated, - }); - } catch (e) { - // Likewise, throwing an error from the asyncMap mapping function - // will result in calling the subscribed error handler function. - throw new ApolloError({ - networkError: e, - }); - } + const storeResult: typeof result = { ...result }; + + if (typeof refetchQueries === "function") { + refetchQueries = refetchQueries(storeResult); + } + + if (errorPolicy === 'ignore' && + graphQLResultHasError(storeResult)) { + delete storeResult.errors; + } + + if (fetchPolicy === 'no-cache') { + const refetchResults = this.refetchQueries({ + include: refetchQueries, + onQueryUpdated, + }); + + return Promise.all( + awaitRefetchQueries ? refetchResults.values() : [], + ).then(() => storeResult); } + + const markPromise = self.markMutationResult< + TData, + TVariables, + TContext, + TCache + >({ + mutationId, + result, + document: mutation, + variables, + errorPolicy, + context, + update: updateWithProxyFn, + updateQueries, + refetchQueries, + removeOptimistic: optimisticResponse ? mutationId : void 0, + onQueryUpdated, + }); + + if (awaitRefetchQueries || onQueryUpdated) { + // Returning the result of markMutationResult here makes the + // mutation await the Promise that markMutationResult returns, + // since we are returning markPromise from the map function + // we passed to asyncMap above. + return markPromise.then(() => storeResult); + } + + return storeResult; }, ).subscribe({ + next(storeResult) { + if (optimisticResponse) { + self.cache.removeOptimistic(mutationId); + } + + self.broadcastQueries(); + + // At the moment, a mutation can have only one result, so we can + // immediately resolve upon receiving the first result. In the future, + // mutations containing @defer or @stream directives might receive + // multiple FetchResult payloads from the ApolloLink chain, so we will + // probably need to collect those results in this next method and call + // resolve only later, in an observer.complete function. + resolve(storeResult); + }, + error(err: Error) { if (mutationStoreValue) { mutationStoreValue.loading = false; @@ -268,41 +305,16 @@ export class QueryManager { }), ); }, - - complete() { - if (optimisticResponse) { - self.cache.removeOptimistic(mutationId); - } - - self.broadcastQueries(); - - // allow for conditional refetches - // XXX do we want to make this the only API one day? - if (typeof refetchQueries === 'function') { - refetchQueries = refetchQueries(storeResult!); - } - - const refetchQueryPromises = self.refetchQueries(refetchQueries); - - Promise.all( - awaitRefetchQueries ? refetchQueryPromises : [], - ).then(() => { - if ( - errorPolicy === 'ignore' && - storeResult && - graphQLResultHasError(storeResult) - ) { - delete storeResult.errors; - } - - resolve(storeResult!); - }, reject); - }, }); }); } - public markMutationResult>( + public markMutationResult< + TData, + TVariables, + TContext, + TCache extends ApolloCache + >( mutation: { mutationId: string; result: FetchResult; @@ -312,7 +324,8 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; - removeOptimistic: boolean; + refetchQueries?: RefetchQueryDescription; + removeOptimistic?: string; onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, @@ -364,43 +377,37 @@ export class QueryManager { }); } - const reobserveResults: any[] = []; + const results = this.refetchQueries({ + updateCache(cache: TCache) { + cacheWrites.forEach(write => cache.write(write)); - cache.batch({ - transaction(c) { - cacheWrites.forEach(write => c.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; if (update) { - update(c as any, mutation.result, { + update(cache, mutation.result, { context: mutation.context, variables: mutation.variables, }); } }, + include: mutation.refetchQueries, + // Write the final mutation.result to the root layer of the cache. optimistic: false, - removeOptimistic: mutation.removeOptimistic - ? mutation.mutationId - : void 0, + // Remove the corresponding optimistic layer at the same time as we + // write the final non-optimistic result. + removeOptimistic: mutation.removeOptimistic, - onWatchUpdated: mutation.onQueryUpdated && ((watch, diff) => { - if (watch.watcher instanceof QueryInfo) { - const oq = watch.watcher.observableQuery; - if (oq) { - reobserveResults.push(mutation.onQueryUpdated!(oq, diff)); - // Prevent the normal cache broadcast of this result. - return false; - } - } - }), + // Let the caller of client.mutate optionally determine the refetching + // behavior for watched queries after the mutation.update function runs. + onQueryUpdated: mutation.onQueryUpdated, }); - return Promise.all(reobserveResults).then(() => void 0); + return Promise.all(results.values()).then(() => void 0); } return Promise.resolve(); @@ -426,7 +433,6 @@ export class QueryManager { try { this.markMutationResult({ ...mutation, - removeOptimistic: false, result: { data }, }, cache); } catch (error) { @@ -1023,38 +1029,138 @@ export class QueryManager { return concast; } - public refetchQueries( - queries: RefetchQueryDescription, - ): Promise>[] { - const refetchQueryPromises: Promise>[] = []; - - if (isNonEmptyArray(queries)) { - queries.forEach(refetchQuery => { - if (typeof refetchQuery === 'string') { - this.queries.forEach(({ observableQuery }) => { - if (observableQuery && - observableQuery.hasObservers() && - observableQuery.queryName === refetchQuery) { - refetchQueryPromises.push(observableQuery.refetch()); + public refetchQueries({ + updateCache, + include, + optimistic = false, + removeOptimistic = optimistic ? "TODO" : void 0, + onQueryUpdated, + }: RefetchQueriesOptions>) { + const includedQueriesById = new Map(); + const results = new Map, any>(); + + if (include) { + const queryIdsByQueryName: Record = Object.create(null); + this.queries.forEach((queryInfo, queryId) => { + const oq = queryInfo.observableQuery; + const queryName = oq && oq.queryName; + if (queryName) { + queryIdsByQueryName[queryName] = queryId; + } + }); + + include.forEach(queryNameOrOptions => { + if (typeof queryNameOrOptions === "string") { + const queryId = queryIdsByQueryName[queryNameOrOptions]; + if (queryId) { + includedQueriesById.set(queryId, queryNameOrOptions); + } else { + invariant.warn(`Unknown query name ${ + JSON.stringify(queryNameOrOptions) + } passed to refetchQueries method in options.include array`); + } + } else { + includedQueriesById.set( + // We will be issuing a fresh network request for this query, so we + // pre-allocate a new query ID here. + this.generateQueryId(), + queryNameOrOptions, + ); + } + }); + } + + if (updateCache) { + this.cache.batch({ + transaction: updateCache, + + // Since you can perform any combination of cache reads and/or writes in + // the cache.batch transaction function, its optimistic option can be + // either a boolean or a string, representing three distinct modes of + // operation: + // + // * false: read/write only the root layer + // * true: read/write the topmost layer + // * string: read/write a fresh optimistic layer with that ID string + // + // When typeof optimistic === "string", a new optimistic layer will be + // temporarily created within cache.batch with that string as its ID. If + // we then pass that same string as the removeOptimistic option, we can + // make cache.batch immediately remove the optimistic layer after + // running the transaction, triggering only one broadcast. + // + // However, the refetchQueries method accepts only true or false for its + // optimistic option (not string). We interpret true to mean a temporary + // optimistic layer should be created, to allow efficiently rolling back + // the effect of the updateCache function, which involves passing a + // string instead of true as the optimistic option to cache.batch, when + // refetchQueries receives optimistic: true. + // + // In other words, we are deliberately not supporting the use case of + // writing to an *existing* optimistic layer (using the refetchQueries + // updateCache function), since that would potentially interfere with + // other optimistic updates in progress. Instead, you can read/write + // only the root layer by passing optimistic: false to refetchQueries, + // or you can read/write a brand new optimistic layer that will be + // automatically removed by passing optimistic: true. + optimistic: optimistic && removeOptimistic || false, + + // The removeOptimistic option can also be provided by itself, even if + // optimistic === false, to remove some previously-added optimistic + // layer safely and efficiently, like we do in markMutationResult. + // + // If an explicit removeOptimistic string is provided with optimistic: + // true, the removeOptimistic string will determine the ID of the + // temporary optimistic layer, in case that ever matters. + removeOptimistic, + + onWatchUpdated: onQueryUpdated && function (watch, diff) { + if (watch.watcher instanceof QueryInfo) { + const oq = watch.watcher.observableQuery; + if (oq) { + includedQueriesById.delete(oq.queryId); + results.set(oq, onQueryUpdated!(oq, diff)); + // Prevent the normal cache broadcast of this result. + return false; } + } + }, + }); + } + + if (includedQueriesById.size) { + includedQueriesById.forEach((queryNameOrOptions, queryId) => { + const queryInfo = this.getQuery(queryId); + let oq = queryInfo.observableQuery; + if (oq) { + const result = onQueryUpdated + ? onQueryUpdated(oq, queryInfo.getDiff()) + : oq.refetch(); + + results.set(oq, result); + + } else if (typeof queryNameOrOptions === "object") { + const fetchPromise = this.fetchQuery(queryId, { + query: queryNameOrOptions.query, + variables: queryNameOrOptions.variables, + fetchPolicy: "network-only", + context: queryNameOrOptions.context, }); - } else { - const queryOptions: QueryOptions = { - query: refetchQuery.query, - variables: refetchQuery.variables, - fetchPolicy: 'network-only', - }; - - if (refetchQuery.context) { - queryOptions.context = refetchQuery.context; + + oq = queryInfo.observableQuery; + if (oq) { + results.set(oq, fetchPromise); + } else { + throw new InvariantError(JSON.stringify(queryInfo, null, 2)); } - refetchQueryPromises.push(this.query(queryOptions)); + const stop = () => this.stopQuery(queryId); + fetchPromise.then(stop, stop); } }); } - return refetchQueryPromises; + return results; } private fetchQueryByPolicy( diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index da43103d649..e14effcf004 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4382,7 +4382,9 @@ describe('QueryManager', () => { observable.subscribe({ next: () => null }); observable2.subscribe({ next: () => null }); - return Promise.all(queryManager.refetchQueries(['GetAuthor', 'GetAuthor2'])).then(() => { + return Promise.all(queryManager.refetchQueries({ + include: ['GetAuthor', 'GetAuthor2'], + })).then(() => { const result = getCurrentQueryResult(observable); expect(result.partial).toBe(false); expect(stripSymbols(result.data)).toEqual(dataChanged); diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index f9f96975d80..c2fd7b74cc0 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -191,6 +191,14 @@ export interface SubscriptionOptions; +export type RefetchQueriesOptions> = { + updateCache?: (cache: Cache) => void; + include?: RefetchQueryDescription; + optimistic?: boolean; + removeOptimistic?: string; + onQueryUpdated?: OnQueryUpdated; +}; + export interface MutationBaseOptions< TData = any, TVariables = OperationVariables,