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; notifyOnNetworkStatusChange?: boolean; context?: Context; @@ -166,6 +169,7 @@ export interface MutationFunctionOptions< refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; update?: MutationUpdaterFn; + reobserveQuery?: ReobserveQueryCallback; context?: Context; fetchPolicy?: WatchQueryFetchPolicy; }