diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16872c372fc..06c78997dab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,9 @@ TBD
```
[@benjamn](https://github.com/benjamn) in [#7810](https://github.com/apollographql/apollo-client/pull/7810)
+- Mutations now accept an optional callback function called `reobserveQuery`, which will be passed the `ObservableQuery` and `Cache.DiffResult` objects for any queries invalidated by cache writes performed by the mutation's final `update` function. Using `reobserveQuery`, you can override the default `FetchPolicy` of the query, by (for example) calling `ObservableQuery` methods like `refetch` to force a network request. This automatic detection of invalidated queries provides an alternative to manually enumerating queries using the `refetchQueries` mutation option. Also, if you return a `Promise` from `reobserveQuery`, the mutation will automatically await that `Promise`, rendering the `awaitRefetchQueries` option unnecessary.
+ [@benjamn](https://github.com/benjamn) in [#7827](https://github.com/apollographql/apollo-client/pull/7827)
+
- Support `client.refetchQueries` as an imperative way to refetch queries, without having to pass `options.refetchQueries` to `client.mutate`.
[@dannycochran](https://github.com/dannycochran) in [#7431](https://github.com/apollographql/apollo-client/pull/7431)
diff --git a/package.json b/package.json
index cd6640076c4..1610e66fe0c 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.cjs.min.js",
- "maxSize": "26.4 kB"
+ "maxSize": "26.5 kB"
}
],
"peerDependencies": {
diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts
index deca58cd166..4eacf9e22f3 100644
--- a/src/cache/core/types/Cache.ts
+++ b/src/cache/core/types/Cache.ts
@@ -26,7 +26,10 @@ export namespace Cache {
// declaring the returnPartialData option.
}
- export interface WatchOptions extends ReadOptions {
+ export interface WatchOptions<
+ Watcher extends object = Record
+ > extends ReadOptions {
+ watcher?: Watcher;
immediate?: boolean;
callback: WatchCallback;
lastDiff?: DiffResult;
diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts
index 671a0261b18..5b77231d34a 100644
--- a/src/core/QueryInfo.ts
+++ b/src/core/QueryInfo.ts
@@ -261,7 +261,7 @@ export class QueryInfo {
// updateWatch method.
private cancel() {}
- private lastWatch?: Cache.WatchOptions;
+ private lastWatch?: Cache.WatchOptions;
private updateWatch(variables = this.variables) {
const oq = this.observableQuery;
@@ -276,6 +276,7 @@ export class QueryInfo {
query: this.document!,
variables,
optimistic: true,
+ watcher: this,
callback: diff => this.setDiff(diff),
});
}
diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts
index 27cbe61bbfc..2924c46584b 100644
--- a/src/core/QueryManager.ts
+++ b/src/core/QueryManager.ts
@@ -35,6 +35,7 @@ import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus';
import {
ApolloQueryResult,
OperationVariables,
+ ReobserveQueryCallback,
} from './types';
import { LocalState } from './LocalState';
@@ -135,6 +136,7 @@ export class QueryManager {
refetchQueries = [],
awaitRefetchQueries = false,
update: updateWithProxyFn,
+ reobserveQuery,
errorPolicy = 'none',
fetchPolicy,
context = {},
@@ -184,23 +186,23 @@ export class QueryManager {
return new Promise((resolve, reject) => {
let storeResult: FetchResult | null;
- let error: ApolloError;
- self.getObservableFromLink(
- mutation,
- {
- ...context,
- optimisticResponse,
- },
- variables,
- false,
- ).subscribe({
- next(result: FetchResult) {
+ return asyncMap(
+ self.getObservableFromLink(
+ mutation,
+ {
+ ...context,
+ optimisticResponse,
+ },
+ variables,
+ false,
+ ),
+
+ (result: FetchResult) => {
if (graphQLResultHasError(result) && errorPolicy === 'none') {
- error = new ApolloError({
+ throw new ApolloError({
graphQLErrors: result.errors,
});
- return;
}
if (mutationStoreValue) {
@@ -208,9 +210,15 @@ export class QueryManager {
mutationStoreValue.error = null;
}
+ storeResult = result;
+
if (fetchPolicy !== 'no-cache') {
try {
- self.markMutationResult({
+ // 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,
@@ -218,51 +226,45 @@ export class QueryManager {
errorPolicy,
updateQueries,
update: updateWithProxyFn,
+ reobserveQuery,
});
} catch (e) {
- error = new ApolloError({
+ // Likewise, throwing an error from the asyncMap mapping function
+ // will result in calling the subscribed error handler function.
+ throw new ApolloError({
networkError: e,
});
- return;
}
}
-
- storeResult = result;
},
+ ).subscribe({
error(err: Error) {
if (mutationStoreValue) {
mutationStoreValue.loading = false;
mutationStoreValue.error = err;
}
+
if (optimisticResponse) {
self.cache.removeOptimistic(mutationId);
}
+
self.broadcastQueries();
+
reject(
- new ApolloError({
+ err instanceof ApolloError ? err : new ApolloError({
networkError: err,
}),
);
},
complete() {
- if (error && mutationStoreValue) {
- mutationStoreValue.loading = false;
- mutationStoreValue.error = error;
- }
-
if (optimisticResponse) {
self.cache.removeOptimistic(mutationId);
}
self.broadcastQueries();
- if (error) {
- reject(error);
- return;
- }
-
// allow for conditional refetches
// XXX do we want to make this the only API one day?
if (typeof refetchQueries === 'function') {
@@ -301,9 +303,10 @@ export class QueryManager {
cache: ApolloCache,
result: FetchResult,
) => void;
+ reobserveQuery?: ReobserveQueryCallback;
},
cache = this.cache,
- ) {
+ ): Promise {
if (shouldWriteResult(mutation.result, mutation.errorPolicy)) {
const cacheWrites: Cache.WriteOptions[] = [{
result: mutation.result.data,
@@ -351,6 +354,8 @@ export class QueryManager {
});
}
+ const reobserveResults: any[] = [];
+
cache.batch({
transaction(c) {
cacheWrites.forEach(write => c.write(write));
@@ -362,10 +367,26 @@ export class QueryManager {
update(c, mutation.result);
}
},
+
// Write the final mutation.result to the root layer of the cache.
optimistic: false,
+
+ onDirty: mutation.reobserveQuery && ((watch, diff) => {
+ if (watch.watcher instanceof QueryInfo) {
+ const oq = watch.watcher.observableQuery;
+ if (oq) {
+ reobserveResults.push(mutation.reobserveQuery!(oq, diff));
+ // Prevent the normal cache broadcast of this result.
+ return false;
+ }
+ }
+ }),
});
+
+ return Promise.all(reobserveResults).then(() => void 0);
}
+
+ return Promise.resolve();
}
public markMutationOptimistic(
diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts
index 232ea8a167c..a0fddec6f31 100644
--- a/src/core/__tests__/QueryManager/index.ts
+++ b/src/core/__tests__/QueryManager/index.ts
@@ -5168,6 +5168,217 @@ describe('QueryManager', () => {
});
});
+ describe('reobserveQuery', () => {
+ const mutation = gql`
+ mutation changeAuthorName {
+ changeAuthorName(newName: "Jack Smith") {
+ firstName
+ lastName
+ }
+ }
+ `;
+
+ const mutationData = {
+ changeAuthorName: {
+ firstName: 'Jack',
+ lastName: 'Smith',
+ },
+ };
+
+ const query = gql`
+ query getAuthors($id: ID!) {
+ author(id: $id) {
+ firstName
+ lastName
+ }
+ }
+ `;
+
+ const data = {
+ author: {
+ firstName: 'John',
+ lastName: 'Smith',
+ },
+ };
+
+ const secondReqData = {
+ author: {
+ firstName: 'Jane',
+ lastName: 'Johnson',
+ },
+ };
+
+ const variables = { id: '1234' };
+
+ function makeQueryManager(reject: (reason?: any) => void) {
+ return mockQueryManager(
+ reject,
+ {
+ request: { query, variables },
+ result: { data },
+ },
+ {
+ request: { query, variables },
+ result: { data: secondReqData },
+ },
+ {
+ request: { query: mutation },
+ result: { data: mutationData },
+ },
+ );
+ }
+
+ itAsync('should refetch the right query when a result is successfully returned', (resolve, reject) => {
+ const queryManager = makeQueryManager(reject);
+
+ const observable = queryManager.watchQuery({
+ query,
+ variables,
+ notifyOnNetworkStatusChange: false,
+ });
+
+ let finishedRefetch = false;
+
+ return observableToPromise(
+ { observable },
+ result => {
+ expect(stripSymbols(result.data)).toEqual(data);
+
+ return queryManager.mutate({
+ mutation,
+
+ update(cache) {
+ cache.modify({
+ fields: {
+ author(_, { INVALIDATE }) {
+ return INVALIDATE;
+ },
+ },
+ });
+ },
+
+ reobserveQuery(obsQuery) {
+ expect(obsQuery.options.query).toBe(query);
+ return obsQuery.refetch().then(async () => {
+ // Wait a bit to make sure the mutation really awaited the
+ // refetching of the query.
+ await new Promise(resolve => setTimeout(resolve, 100));
+ finishedRefetch = true;
+ });
+ },
+ }).then(() => {
+ expect(finishedRefetch).toBe(true);
+ });
+ },
+
+ result => {
+ expect(stripSymbols(observable.getCurrentResult().data)).toEqual(
+ secondReqData,
+ );
+ expect(stripSymbols(result.data)).toEqual(secondReqData);
+ expect(finishedRefetch).toBe(true);
+ },
+ ).then(resolve, reject);
+ });
+
+ itAsync('should refetch using the original query context (if any)', (resolve, reject) => {
+ const queryManager = makeQueryManager(reject);
+
+ const headers = {
+ someHeader: 'some value',
+ };
+
+ const observable = queryManager.watchQuery({
+ query,
+ variables,
+ context: {
+ headers,
+ },
+ notifyOnNetworkStatusChange: false,
+ });
+
+ return observableToPromise(
+ { observable },
+ result => {
+ expect(result.data).toEqual(data);
+
+ queryManager.mutate({
+ mutation,
+
+ update(cache) {
+ cache.modify({
+ fields: {
+ author(_, { INVALIDATE }) {
+ return INVALIDATE;
+ },
+ },
+ });
+ },
+
+ reobserveQuery(obsQuery) {
+ expect(obsQuery.options.query).toBe(query);
+ return obsQuery.refetch();
+ },
+ });
+ },
+
+ result => {
+ expect(result.data).toEqual(secondReqData);
+ const context = (queryManager.link as MockApolloLink).operation!.getContext();
+ expect(context.headers).not.toBeUndefined();
+ expect(context.headers.someHeader).toEqual(headers.someHeader);
+ },
+ ).then(resolve, reject);
+ });
+
+ itAsync('should refetch using the specified context, if provided', (resolve, reject) => {
+ const queryManager = makeQueryManager(reject);
+
+ const observable = queryManager.watchQuery({
+ query,
+ variables,
+ notifyOnNetworkStatusChange: false,
+ });
+
+ const headers = {
+ someHeader: 'some value',
+ };
+
+ return observableToPromise(
+ { observable },
+ result => {
+ expect(result.data).toEqual(data);
+
+ queryManager.mutate({
+ mutation,
+
+ update(cache) {
+ cache.evict({ fieldName: "author" });
+ },
+
+ reobserveQuery(obsQuery) {
+ expect(obsQuery.options.query).toBe(query);
+ return obsQuery.reobserve({
+ fetchPolicy: "network-only",
+ context: {
+ ...obsQuery.options.context,
+ headers,
+ },
+ });
+ },
+ });
+ },
+
+ result => {
+ expect(result.data).toEqual(secondReqData);
+ const context = (queryManager.link as MockApolloLink).operation!.getContext();
+ expect(context.headers).not.toBeUndefined();
+ expect(context.headers.someHeader).toEqual(headers.someHeader);
+ },
+ ).then(resolve, reject);
+ });
+ });
+
describe('awaitRefetchQueries', () => {
const awaitRefetchTest =
({ awaitRefetchQueries, testQueryError = false }: MutationBaseOptions & { testQueryError?: boolean }) =>
diff --git a/src/core/types.ts b/src/core/types.ts
index b139672fc52..b90b84e35e0 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -5,11 +5,18 @@ import { ApolloError } from '../errors';
import { QueryInfo } from './QueryInfo';
import { NetworkStatus } from './networkStatus';
import { Resolver } from './LocalState';
+import { ObservableQuery } from './ObservableQuery';
+import { Cache } from '../cache';
export { TypedDocumentNode } from '@graphql-typed-document-node/core';
export type QueryListener = (queryInfo: QueryInfo) => void;
+export type ReobserveQueryCallback = (
+ observableQuery: ObservableQuery,
+ diff: Cache.DiffResult,
+) => void | Promise;
+
export type OperationVariables = Record;
export type PureQueryOptions = {
diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts
index c00e77cc969..e6e2fd0efe9 100644
--- a/src/core/watchQueryOptions.ts
+++ b/src/core/watchQueryOptions.ts
@@ -3,7 +3,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { ApolloCache } from '../cache';
import { FetchResult } from '../link/core';
-import { MutationQueryReducersMap } from './types';
+import { MutationQueryReducersMap, ReobserveQueryCallback } from './types';
import { PureQueryOptions, OperationVariables } from './types';
/**
@@ -241,6 +241,12 @@ export interface MutationBaseOptions<
*/
update?: MutationUpdaterFn;
+ /**
+ * A function that will be called for each ObservableQuery affected by
+ * this mutation, after the mutation has completed.
+ */
+ reobserveQuery?: ReobserveQueryCallback;
+
/**
* Specifies the {@link ErrorPolicy} to be used for this operation
*/
diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx
index de541810bb7..ed0d4893baa 100644
--- a/src/react/hooks/__tests__/useMutation.test.tsx
+++ b/src/react/hooks/__tests__/useMutation.test.tsx
@@ -3,11 +3,13 @@ import { DocumentNode, GraphQLError } from 'graphql';
import gql from 'graphql-tag';
import { render, cleanup, wait } from '@testing-library/react';
-import { ApolloClient } from '../../../core';
+import { ApolloClient, ApolloQueryResult, Cache, NetworkStatus, ObservableQuery, TypedDocumentNode } from '../../../core';
import { InMemoryCache } from '../../../cache';
import { itAsync, MockedProvider, mockSingleLink } from '../../../testing';
import { ApolloProvider } from '../../context';
+import { useQuery } from '../useQuery';
import { useMutation } from '../useMutation';
+import { act } from 'react-dom/test-utils';
describe('useMutation Hook', () => {
interface Todo {
@@ -503,4 +505,171 @@ describe('useMutation Hook', () => {
}).then(resolve, reject);
});
});
+
+ describe('refetching queries', () => {
+ itAsync('can pass reobserveQuery to useMutation', (resolve, reject) => {
+ interface TData {
+ todoCount: number;
+ }
+ const countQuery: TypedDocumentNode = gql`
+ query Count { todoCount @client }
+ `;
+
+ const optimisticResponse = {
+ __typename: 'Mutation',
+ createTodo: {
+ id: 1,
+ description: 'TEMPORARY',
+ priority: 'High',
+ __typename: 'Todo'
+ }
+ };
+
+ const variables = {
+ description: 'Get milk!'
+ };
+
+ const client = new ApolloClient({
+ cache: new InMemoryCache({
+ typePolicies: {
+ Query: {
+ fields: {
+ todoCount(count = 0) {
+ return count;
+ },
+ },
+ },
+ },
+ }),
+
+ link: mockSingleLink({
+ request: {
+ query: CREATE_TODO_MUTATION,
+ variables,
+ },
+ result: { data: CREATE_TODO_RESULT },
+ }).setOnError(reject),
+ });
+
+ // The goal of this test is to make sure reobserveQuery gets called as
+ // part of the createTodo mutation, so we use this reobservePromise to
+ // await the calling of reobserveQuery.
+ interface ReobserveResults {
+ obsQuery: ObservableQuery;
+ diff: Cache.DiffResult;
+ result: ApolloQueryResult;
+ }
+ let reobserveResolve: (results: ReobserveResults) => any;
+ const reobservePromise = new Promise(resolve => {
+ reobserveResolve = resolve;
+ });
+ let finishedReobserving = false;
+
+ let renderCount = 0;
+ function Component() {
+ const count = useQuery(countQuery);
+
+ const [createTodo, { loading, data }] =
+ useMutation(CREATE_TODO_MUTATION, {
+ optimisticResponse,
+
+ update(cache, mutationResult) {
+ const result = cache.readQuery({
+ query: countQuery,
+ });
+
+ cache.writeQuery({
+ query: countQuery,
+ data: {
+ todoCount: (result ? result.todoCount : 0) + 1,
+ },
+ });
+ },
+ });
+
+ switch (++renderCount) {
+ case 1:
+ expect(count.loading).toBe(false);
+ expect(count.data).toEqual({ todoCount: 0 });
+
+ expect(loading).toBeFalsy();
+ expect(data).toBeUndefined();
+
+ act(() => {
+ createTodo({
+ variables,
+ reobserveQuery(obsQuery, diff) {
+ return obsQuery.reobserve().then(result => {
+ finishedReobserving = true;
+ reobserveResolve({ obsQuery, diff, result });
+ });
+ },
+ });
+ });
+
+ break;
+ case 2:
+ expect(count.loading).toBe(false);
+ expect(count.data).toEqual({ todoCount: 0 });
+
+ expect(loading).toBeTruthy();
+ expect(data).toBeUndefined();
+
+ expect(finishedReobserving).toBe(false);
+ break;
+ case 3:
+ expect(count.loading).toBe(false);
+ expect(count.data).toEqual({ todoCount: 1 });
+
+ expect(loading).toBe(true);
+ expect(data).toBeUndefined();
+
+ expect(finishedReobserving).toBe(false);
+ break;
+ case 4:
+ expect(count.loading).toBe(false);
+ expect(count.data).toEqual({ todoCount: 1 });
+
+ expect(loading).toBe(false);
+ expect(data).toEqual(CREATE_TODO_RESULT);
+
+ expect(finishedReobserving).toBe(true);
+ break;
+ default:
+ reject("too many renders");
+ }
+
+ return null;
+ }
+
+ render(
+
+
+
+ );
+
+ return reobservePromise.then(results => {
+ expect(finishedReobserving).toBe(true);
+
+ expect(results.diff).toEqual({
+ complete: true,
+ result: {
+ todoCount: 1,
+ },
+ });
+
+ expect(results.result).toEqual({
+ loading: false,
+ networkStatus: NetworkStatus.ready,
+ data: {
+ todoCount: 1,
+ },
+ });
+
+ return wait(() => {
+ expect(renderCount).toBe(4);
+ }).then(resolve, reject);
+ });
+ });
+ });
});
diff --git a/src/react/types/types.ts b/src/react/types/types.ts
index cbe3d2750fb..c91f837f342 100644
--- a/src/react/types/types.ts
+++ b/src/react/types/types.ts
@@ -4,9 +4,9 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { Observable } from '../../utilities';
import { FetchResult } from '../../link/core';
-import { ApolloClient, WatchQueryOptions } from '../../core';
import { ApolloError } from '../../errors';
import {
+ ApolloClient,
ApolloQueryResult,
ErrorPolicy,
FetchMoreOptions,
@@ -17,7 +17,9 @@ import {
ObservableQuery,
OperationVariables,
PureQueryOptions,
+ ReobserveQueryCallback,
WatchQueryFetchPolicy,
+ WatchQueryOptions,
} from '../../core';
/* Common types */
@@ -148,6 +150,7 @@ export interface BaseMutationOptions<
awaitRefetchQueries?: boolean;
errorPolicy?: ErrorPolicy;
update?: MutationUpdaterFn;
+ reobserveQuery?: ReobserveQueryCallback;
client?: ApolloClient