diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5abd61956..6f4e29418fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,12 +34,15 @@ - `InMemoryCache` supports a new method called `batch`, which is similar to `performTransaction` but takes named options rather than positional parameters. One of these named options is an `onDirty(watch, diff)` callback, which can be used to determine which watched queries were invalidated by the `batch` operation.
[@benjamn](https://github.com/benjamn) in [#7819](https://github.com/apollographql/apollo-client/pull/7819) -- 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.
+- Mutations now accept an optional callback function called `onQueryUpdated`, 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 `onQueryUpdated`, 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 `onQueryUpdated`, 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) +- Improve standalone `client.refetchQueries` method to support automatic detection of queries needing to be refetched.
+ [@benjamn](https://github.com/benjamn) in [#8000](https://github.com/apollographql/apollo-client/pull/8000) + - When `@apollo/client` is imported as CommonJS (for example, in Node.js), the global `process` variable is now shadowed with a stripped-down object that includes only `process.env.NODE_ENV` (since that's all Apollo Client needs), eliminating the significant performance penalty of repeatedly accessing `process.env` at runtime.
[@benjamn](https://github.com/benjamn) in [#7627](https://github.com/apollographql/apollo-client/pull/7627) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index cf064e3d32a..3032b34917e 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -347,12 +347,14 @@ Array [ "graphQLResultHasError", "hasClientExports", "hasDirectives", + "isDocumentNode", "isField", "isInlineFragment", "isNonEmptyArray", "isReference", "iterateObserversSafely", "makeReference", + "makeUniqueId", "maybeDeepFreeze", "mergeDeep", "mergeDeepArray", diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index af13183cef1..446693a53d9 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -2428,16 +2428,18 @@ describe('client', () => { }); it('has a refetchQueries method which calls QueryManager', async () => { - // TODO(dannycochran) const client = new ApolloClient({ link: ApolloLink.empty(), cache: new InMemoryCache(), }); - // @ts-ignore - const spy = jest.spyOn(client.queryManager, 'refetchQueries'); - await client.refetchQueries(['Author1']); - expect(spy).toHaveBeenCalled(); + const spy = jest.spyOn(client['queryManager'], 'refetchQueries'); + spy.mockImplementation(() => new Map); + + const options = { include: ['Author1'] }; + await client.refetchQueries(options); + + expect(spy).toHaveBeenCalledWith(options); }); itAsync('should propagate errors from network interface to observers', (resolve, reject) => { diff --git a/src/__tests__/refetchQueries.ts b/src/__tests__/refetchQueries.ts new file mode 100644 index 00000000000..d5b5bd72236 --- /dev/null +++ b/src/__tests__/refetchQueries.ts @@ -0,0 +1,678 @@ +import { Subscription } from "zen-observable-ts"; + +import { itAsync } from '../utilities/testing/itAsync'; +import { + ApolloClient, + ApolloLink, + InMemoryCache, + gql, + Observable, + TypedDocumentNode, + ObservableQuery, +} from "../core"; + +describe("client.refetchQueries", () => { + itAsync("is public and callable", (resolve, reject) => { + const client = new ApolloClient({ + cache: new InMemoryCache, + }); + expect(typeof client.refetchQueries).toBe("function"); + + const result = client.refetchQueries({ + updateCache(cache) { + expect(cache).toBe(client.cache); + expect(cache.extract()).toEqual({}); + }, + onQueryUpdated() { + reject("should not have called onQueryUpdated"); + return false; + }, + }); + + expect(result.queries).toEqual([]); + expect(result.results).toEqual([]); + + result.then(resolve, reject); + }); + + const aQuery: TypedDocumentNode<{ a: string }> = gql`query A { a }`; + const bQuery: TypedDocumentNode<{ b: string }> = gql`query B { b }`; + const abQuery: TypedDocumentNode<{ + a: string; + b: string; + }> = gql`query AB { a b }`; + + function makeClient() { + return new ApolloClient({ + cache: new InMemoryCache, + link: new ApolloLink(operation => new Observable(observer => { + const data: Record = {}; + operation.operationName.split("").forEach(letter => { + data[letter.toLowerCase()] = letter.toUpperCase(); + }); + observer.next({ data }); + observer.complete(); + })), + }); + } + + const subs: Subscription[] = []; + function unsubscribe() { + subs.splice(0).forEach(sub => sub.unsubscribe()); + } + + function setup(client = makeClient()) { + function watch(query: TypedDocumentNode) { + const obsQuery = client.watchQuery({ query }); + return new Promise>((resolve, reject) => { + subs.push(obsQuery.subscribe({ + error: reject, + next(result) { + expect(result.loading).toBe(false); + resolve(obsQuery); + }, + })); + }); + } + + return Promise.all([ + watch(aQuery), + watch(bQuery), + watch(abQuery), + ]); + } + + // Not a great way to sort objects, but it will give us stable orderings in + // these specific tests (especially since the keys are all "a" and/or "b"). + function sortObjects(array: T) { + array.sort((a, b) => { + const aKey = Object.keys(a).join(","); + const bKey = Object.keys(b).join(","); + if (aKey < bKey) return -1; + if (bKey < aKey) return 1; + return 0; + }); + } + + itAsync("includes watched queries affected by updateCache", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + reject("bQuery should not have been updated"); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Note that no bQuery result is included here. + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + reject("aQuery should not have been updated"); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + // Note that no aQuery result is included here. + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + + itAsync("includes watched queries named in options.include", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + // This is the options.include array mentioned in the test description. + include: ["B"], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Included this time! + { b: "B" }, + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + // The "A" here causes aObs to be included, but the "AB" should be + // redundant because that query is already included. + include: ["A", "AB"], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + { a: "Ayy" }, // Included this time! + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + + itAsync("includes query DocumentNode objects specified in options.include", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const ayyResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + + // Note that we're passing query DocumentNode objects instead of query + // name strings, in this test. + include: [bQuery, abQuery], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return Promise.resolve(diff.result); + }, + }); + + sortObjects(ayyResults); + + expect(ayyResults).toEqual([ + { a: "Ayy" }, + { a: "Ayy", b: "B" }, + // Included this time! + { b: "B" }, + ]); + + const beeResults = await client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Bee", + }, + }); + }, + + // The abQuery and "AB" should be redundant, but the aQuery here is + // important for aObs to be included. + include: [abQuery, "AB", aQuery], + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "Ayy" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Bee" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "Ayy", b: "Bee" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return diff.result; + }, + }); + + sortObjects(beeResults); + + expect(beeResults).toEqual([ + { a: "Ayy" }, // Included this time! + { a: "Ayy", b: "Bee" }, + { b: "Bee" }, + ]); + + unsubscribe(); + resolve(); + }); + + itAsync("refetches watched queries if onQueryUpdated not provided", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const aSpy = jest.spyOn(aObs, "refetch"); + const bSpy = jest.spyOn(bObs, "refetch"); + const abSpy = jest.spyOn(abObs, "refetch"); + + const ayyResults = ( + await client.refetchQueries({ + include: ["B"], + updateCache(cache) { + cache.writeQuery({ + query: aQuery, + data: { + a: "Ayy", + }, + }); + }, + }) + ).map(result => result.data as object); + + sortObjects(ayyResults); + + // These results have reverted back to what the ApolloLink returns ("A" + // rather than "Ayy"), because we let them be refetched (by not providing + // an onQueryUpdated function). + expect(ayyResults).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + { b: "B" }, + ]); + + expect(aSpy).toHaveBeenCalledTimes(1); + expect(bSpy).toHaveBeenCalledTimes(1); + expect(abSpy).toHaveBeenCalledTimes(1); + + unsubscribe(); + resolve(); + }); + + itAsync("can run updateQuery function against optimistic cache layer", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + client.cache.watch({ + query: abQuery, + optimistic: false, + callback(diff) { + reject("should not have notified non-optimistic watcher"); + }, + }); + + expect(client.cache.extract(true)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "a": "A", + "b": "B", + }, + }); + + const results = await client.refetchQueries({ + // This causes the update to run against a temporary optimistic layer. + optimistic: true, + + updateCache(cache) { + const modified = cache.modify({ + fields: { + a(value, { DELETE }) { + expect(value).toEqual("A"); + return DELETE; + }, + }, + }); + expect(modified).toBe(true); + }, + + onQueryUpdated(obs, diff) { + expect(diff.complete).toBe(true); + + // Even though we evicted the Query.a field in the updateCache function, + // that optimistic layer was discarded before broadcasting results, so + // we're back to the original (non-optimistic) data. + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + reject("bQuery should not have been updated"); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "B" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + + return diff.result; + }, + }); + + sortObjects(results); + + expect(results).toEqual([ + { a: "A" }, + { a: "A", b: "B" }, + ]); + + expect(client.cache.extract(true)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + "a": "A", + "b": "B", + }, + }); + + resolve(); + }); + + itAsync("can return true from onQueryUpdated to choose default refetching behavior", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + include: ["A", "B"], + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + reject("abQuery should not have been updated"); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + return true; + }, + }); + + expect(refetchResult.results.length).toBe(2); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["A", "B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + { a: "A" }, + { b: "B" }, + ]); + + resolve(); + }); + + itAsync("can return true from onQueryUpdated when using options.updateCache", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + updateCache(cache) { + cache.writeQuery({ + query: bQuery, + data: { + b: "Beetlejuice" + }, + }); + }, + + onQueryUpdated(obs, diff) { + if (obs === aObs) { + reject("aQuery should not have been updated"); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "Beetlejuice" }); + } else if (obs === abObs) { + expect(diff.result).toEqual({ a: "A", b: "Beetlejuice" }); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + a: "A", + b: "Beetlejuice", + }, + }); + + return true; + }, + }); + + expect(refetchResult.results.length).toBe(2); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["AB", "B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + // Since we returned true from onQueryUpdated, the results were refetched, + // replacing "Beetlejuice" with "B" again. + { a: "A", b: "B"}, + { b: "B" }, + ]); + + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + a: "A", + b: "B", + }, + }); + + resolve(); + }); + + itAsync("can return false from onQueryUpdated to skip/ignore a query", async (resolve, reject) => { + const client = makeClient(); + const [ + aObs, + bObs, + abObs, + ] = await setup(client); + + const refetchResult = client.refetchQueries({ + include: ["A", "B"], + onQueryUpdated(obs, diff) { + if (obs === aObs) { + expect(diff.result).toEqual({ a: "A" }); + } else if (obs === bObs) { + expect(diff.result).toEqual({ b: "B" }); + } else if (obs === abObs) { + reject("abQuery should not have been updated"); + } else { + reject(`unexpected ObservableQuery ${ + obs.queryId + } with name ${obs.queryName}`); + } + // Skip refetching all but the B query. + return obs.queryName === "B"; + }, + }); + + expect(refetchResult.results.length).toBe(1); + refetchResult.results.forEach(result => { + expect(result).toBeInstanceOf(Promise); + }); + + expect(refetchResult.queries.map(obs => { + expect(obs).toBeInstanceOf(ObservableQuery); + return obs.queryName; + }).sort()).toEqual(["B"]); + + const results = (await refetchResult).map(result => { + // These results are ApolloQueryResult, as inferred by TypeScript. + expect(Object.keys(result).sort()).toEqual([ + "data", + "loading", + "networkStatus", + ]); + return result.data; + }); + + sortObjects(results); + + expect(results).toEqual([ + { b: "B" }, + ]); + + resolve(); + }); + + it("can refetch no-cache queries", () => { + // TODO The options.updateCache function won't work for these queries, but + // the options.include array should work, at least. + }); +}); diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 4c188fe83d9..db5c63879a8 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -11,28 +11,6 @@ import { Cache } from './types/Cache'; export type Transaction = (c: ApolloCache) => void; -export type BatchOptions> = { - // Same as the first parameter of performTransaction, except the cache - // argument will have the subclass type rather than ApolloCache. - transaction(cache: C): void; - - // Passing a string for this option creates a new optimistic layer with - // that string as its layer.id, just like passing a string for the - // optimisticId parameter of performTransaction. Passing true is the - // same as passing undefined to performTransaction, and passing false is - // the same as passing null. - optimistic: string | boolean; - - // If you want to find out which watched queries were invalidated during - // this batch operation, pass this optional callback function. Returning - // false from the callback will prevent broadcasting this result. - onDirty?: ( - this: C, - watch: Cache.WatchOptions, - diff: Cache.DiffResult, - ) => void | false; -}; - export abstract class ApolloCache implements DataProxy { // required to implement // core API @@ -81,11 +59,11 @@ export abstract class ApolloCache implements DataProxy { // provide a default batch implementation that's just another way of calling // performTransaction. Subclasses of ApolloCache (such as InMemoryCache) can // override the batch method to do more interesting things with its options. - public batch(options: BatchOptions) { + public batch(options: Cache.BatchOptions) { const optimisticId = typeof options.optimistic === "string" ? options.optimistic : options.optimistic === false ? null : void 0; - this.performTransaction(options.transaction, optimisticId); + this.performTransaction(options.update, optimisticId); } public abstract performTransaction( diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 98b0fb00f48..98a21afd4f5 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -1,8 +1,12 @@ import { DataProxy } from './DataProxy'; import { Modifier, Modifiers } from './common'; +import { ApolloCache } from '../cache'; export namespace Cache { - export type WatchCallback = (diff: Cache.DiffResult) => void; + export type WatchCallback = ( + diff: Cache.DiffResult, + lastDiff?: Cache.DiffResult, + ) => void; export interface ReadOptions extends DataProxy.Query { @@ -50,6 +54,39 @@ export namespace Cache { broadcast?: boolean; } + export interface BatchOptions> { + // Same as the first parameter of performTransaction, except the cache + // argument will have the subclass type rather than ApolloCache. + update(cache: C): void; + + // Passing a string for this option creates a new optimistic layer, with the + // given string as its layer.id, just like passing a string for the + // optimisticId parameter of performTransaction. Passing true is the same as + // passing undefined to performTransaction (running the batch operation + // against the current top layer of the cache), and passing false is the + // same as passing null (running the operation against root/non-optimistic + // cache data). + optimistic: string | boolean; + + // If you specify the ID of an optimistic layer using this option, that + // layer will be removed as part of the batch transaction, triggering at + // most one broadcast for both the transaction and the removal of the layer. + // Note: this option is needed because calling cache.removeOptimistic during + // the transaction function may not be not safe, since any modifications to + // cache layers may be discarded after the transaction finishes. + removeOptimistic?: string; + + // If you want to find out which watched queries were invalidated during + // this batch operation, pass this optional callback function. Returning + // false from the callback will prevent broadcasting this result. + onWatchUpdated?: ( + this: C, + watch: Cache.WatchOptions, + diff: Cache.DiffResult, + lastDiff: Cache.DiffResult | undefined, + ) => any; + } + export import DiffResult = DataProxy.DiffResult; export import ReadQueryOptions = DataProxy.ReadQueryOptions; export import ReadFragmentOptions = DataProxy.ReadFragmentOptions; diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 08fc4766c44..1db6fc073c6 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -1348,7 +1348,7 @@ describe('Cache', () => { return { diffs, watch: options, cancel }; } - it('calls onDirty for each invalidated watch', () => { + it('calls onWatchUpdated for each invalidated watch', () => { const cache = new InMemoryCache; const aQuery = gql`query { a }`; @@ -1362,7 +1362,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.writeQuery({ query: aQuery, data: { @@ -1371,7 +1371,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1403,7 +1403,7 @@ describe('Cache', () => { dirtied.clear(); cache.batch({ - transaction(cache) { + update(cache) { cache.writeQuery({ query: bQuery, data: { @@ -1412,7 +1412,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1474,7 +1474,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.modify({ fields: { a(value, { INVALIDATE }) { @@ -1485,7 +1485,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(w, diff) { + onWatchUpdated(w, diff) { dirtied.set(w, diff); }, }); @@ -1506,7 +1506,7 @@ describe('Cache', () => { bInfo.cancel(); }); - it('does not pass previously invalidated queries to onDirty', () => { + it('does not pass previously invalidated queries to onWatchUpdated', () => { const cache = new InMemoryCache; const aQuery = gql`query { a }`; @@ -1527,13 +1527,13 @@ describe('Cache', () => { cache.writeQuery({ query: bQuery, - // Writing this data with broadcast:false queues this update for the - // next broadcast, whenever it happens. If that next broadcast is the - // one triggered by cache.batch, the bQuery broadcast could be - // accidentally intercepted by onDirty, even though the transaction - // does not touch the Query.b field. To solve this problem, the batch - // method calls cache.broadcastWatches() before the transaction, when - // options.onDirty is provided. + // Writing this data with broadcast:false queues this update for + // the next broadcast, whenever it happens. If that next broadcast + // is the one triggered by cache.batch, the bQuery broadcast could + // be accidentally intercepted by onWatchUpdated, even though the + // transaction does not touch the Query.b field. To solve this + // problem, the batch method calls cache.broadcastWatches() before + // the transaction, when options.onWatchUpdated is provided. broadcast: false, data: { b: "beeeee", @@ -1548,7 +1548,7 @@ describe('Cache', () => { const dirtied = new Map>(); cache.batch({ - transaction(cache) { + update(cache) { cache.modify({ fields: { a(value) { @@ -1559,7 +1559,7 @@ describe('Cache', () => { }); }, optimistic: true, - onDirty(watch, diff) { + onWatchUpdated(watch, diff) { dirtied.set(watch, diff); }, }); @@ -1571,7 +1571,7 @@ describe('Cache', () => { expect(aInfo.diffs).toEqual([ // This diff resulted from the cache.modify call in the cache.batch - // transaction function. + // update function. { complete: true, result: { @@ -1582,7 +1582,7 @@ describe('Cache', () => { expect(abInfo.diffs).toEqual([ // This diff resulted from the cache.modify call in the cache.batch - // transaction function. + // update function. { complete: true, result: { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 5bf4792e026..82afc70a73d 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -642,18 +642,44 @@ class Layer extends EntityStore { const parent = this.parent.removeLayer(layerId); if (layerId === this.id) { - // Dirty every ID we're removing. if (this.group.caching) { + // Dirty every ID we're removing. Technically we might be able to avoid + // dirtying fields that have values in higher layers, but we don't have + // easy access to higher layers here, and we're about to recreate those + // layers anyway (see parent.addLayer below). Object.keys(this.data).forEach(dataId => { - // If this.data[dataId] contains nothing different from what - // lies beneath, we can avoid dirtying this dataId and all of - // its fields, and simply discard this Layer. The only reason we - // call this.delete here is to dirty the removed fields. - if (this.data[dataId] !== (parent as Layer).lookup(dataId)) { + const ownStoreObject = this.data[dataId]; + const parentStoreObject = parent["lookup"](dataId); + if (!parentStoreObject) { + // The StoreObject identified by dataId was defined in this layer + // but will be undefined in the parent layer, so we can delete the + // whole entity using this.delete(dataId). Since we're about to + // throw this layer away, the only goal of this deletion is to dirty + // the removed fields. this.delete(dataId); + } else if (!ownStoreObject) { + // This layer had an entry for dataId but it was undefined, which + // means the entity was deleted in this layer, and it's about to + // become undeleted when we remove this layer, so we need to dirty + // all fields that are about to be reexposed. + this.group.dirty(dataId, "__exists"); + Object.keys(parentStoreObject).forEach(storeFieldName => { + this.group.dirty(dataId, storeFieldName); + }); + } else if (ownStoreObject !== parentStoreObject) { + // If ownStoreObject is not exactly the same as parentStoreObject, + // dirty any fields whose values will change as a result of this + // removal. + Object.keys(ownStoreObject).forEach(storeFieldName => { + if (!equal(ownStoreObject[storeFieldName], + parentStoreObject[storeFieldName])) { + this.group.dirty(dataId, storeFieldName); + } + }); } }); } + return parent; } diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 0f059478c0d..a154204250d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -3,8 +3,9 @@ import './fixPolyfills'; import { DocumentNode } from 'graphql'; import { OptimisticWrapperFunction, wrap } from 'optimism'; +import { equal } from '@wry/equality'; -import { ApolloCache, BatchOptions } from '../core/cache'; +import { ApolloCache } from '../core/cache'; import { Cache } from '../core/types/Cache'; import { MissingFieldError } from '../core/types/common'; import { @@ -38,9 +39,9 @@ export interface InMemoryCacheConfig extends ApolloReducerConfig { } type BroadcastOptions = Pick< - BatchOptions, - | "onDirty" + Cache.BatchOptions, | "optimistic" + | "onWatchUpdated" > const defaultConfig: InMemoryCacheConfig = { @@ -338,10 +339,12 @@ export class InMemoryCache extends ApolloCache { private txCount = 0; - public batch(options: BatchOptions) { + public batch(options: Cache.BatchOptions) { const { - transaction, + update, optimistic = true, + removeOptimistic, + onWatchUpdated, } = options; const perform = (layer?: EntityStore) => { @@ -351,7 +354,7 @@ export class InMemoryCache extends ApolloCache { this.data = this.optimisticData = layer; } try { - transaction(this); + update(this); } finally { --this.txCount; this.data = data; @@ -359,22 +362,21 @@ export class InMemoryCache extends ApolloCache { } }; - const { onDirty } = options; const alreadyDirty = new Set(); - if (onDirty && !this.txCount) { - // If an options.onDirty callback is provided, we want to call it with - // only the Cache.WatchOptions objects affected by options.transaction, + if (onWatchUpdated && !this.txCount) { + // If an options.onWatchUpdated callback is provided, we want to call it + // with only the Cache.WatchOptions objects affected by options.update, // but there might be dirty watchers already waiting to be broadcast that - // have nothing to do with the transaction. To prevent including those - // watchers in the post-transaction broadcast, we perform this initial - // broadcast to collect the dirty watchers, so we can re-dirty them later, - // after the post-transaction broadcast, allowing them to receive their - // pending broadcasts the next time broadcastWatches is called, just as - // they would if we never called cache.batch. + // have nothing to do with the update. To prevent including those watchers + // in the post-update broadcast, we perform this initial broadcast to + // collect the dirty watchers, so we can re-dirty them later, after the + // post-update broadcast, allowing them to receive their pending + // broadcasts the next time broadcastWatches is called, just as they would + // if we never called cache.batch. this.broadcastWatches({ ...options, - onDirty(watch) { + onWatchUpdated(watch) { alreadyDirty.add(watch); return false; }, @@ -388,52 +390,57 @@ export class InMemoryCache extends ApolloCache { this.optimisticData = this.optimisticData.addLayer(optimistic, perform); } else if (optimistic === false) { // Ensure both this.data and this.optimisticData refer to the root - // (non-optimistic) layer of the cache during the transaction. Note - // that this.data could be a Layer if we are currently executing an - // optimistic transaction function, but otherwise will always be an - // EntityStore.Root instance. + // (non-optimistic) layer of the cache during the update. Note that + // this.data could be a Layer if we are currently executing an optimistic + // update function, but otherwise will always be an EntityStore.Root + // instance. perform(this.data); } else { - // Otherwise, leave this.data and this.optimisticData unchanged and - // run the transaction with broadcast batching. + // Otherwise, leave this.data and this.optimisticData unchanged and run + // the update with broadcast batching. perform(); } + if (typeof removeOptimistic === "string") { + this.optimisticData = this.optimisticData.removeLayer(removeOptimistic); + } + // Note: if this.txCount > 0, then alreadyDirty.size === 0, so this code // takes the else branch and calls this.broadcastWatches(options), which // does nothing when this.txCount > 0. - if (onDirty && alreadyDirty.size) { + if (onWatchUpdated && alreadyDirty.size) { this.broadcastWatches({ ...options, - onDirty(watch, diff) { - const onDirtyResult = onDirty.call(this, watch, diff); - if (onDirtyResult !== false) { - // Since onDirty did not return false, this diff is about to be - // broadcast to watch.callback, so we don't need to re-dirty it - // with the other alreadyDirty watches below. + onWatchUpdated(watch, diff) { + const result = onWatchUpdated.call(this, watch, diff); + if (result !== false) { + // Since onWatchUpdated did not return false, this diff is + // about to be broadcast to watch.callback, so we don't need + // to re-dirty it with the other alreadyDirty watches below. alreadyDirty.delete(watch); } - return onDirtyResult; + return result; } }); - // Silently re-dirty any watches that were already dirty before the - // transaction was performed, and were not broadcast just now. + // Silently re-dirty any watches that were already dirty before the update + // was performed, and were not broadcast just now. if (alreadyDirty.size) { alreadyDirty.forEach(watch => this.maybeBroadcastWatch.dirty(watch)); } } else { - // If alreadyDirty is empty or we don't have an options.onDirty function, - // we don't need to go to the trouble of wrapping options.onDirty. + // If alreadyDirty is empty or we don't have an onWatchUpdated + // function, we don't need to go to the trouble of wrapping + // options.onWatchUpdated. this.broadcastWatches(options); } } public performTransaction( - transaction: (cache: InMemoryCache) => any, + update: (cache: InMemoryCache) => any, optimisticId?: string | null, ) { return this.batch({ - transaction, + update, optimistic: optimisticId || (optimisticId !== null), }); } @@ -470,6 +477,7 @@ export class InMemoryCache extends ApolloCache { c: Cache.WatchOptions, options?: BroadcastOptions, ) { + const { lastDiff } = c; const diff = this.diff({ query: c.query, variables: c.variables, @@ -482,16 +490,16 @@ export class InMemoryCache extends ApolloCache { diff.fromOptimisticTransaction = true; } - if (options.onDirty && - options.onDirty.call(this, c, diff) === false) { - // Returning false from the onDirty callback will prevent calling - // c.callback(diff) for this watcher. + if (options.onWatchUpdated && + options.onWatchUpdated.call(this, c, diff, lastDiff) === false) { + // Returning false from the onWatchUpdated callback will prevent + // calling c.callback(diff) for this watcher. return; } } - if (!c.lastDiff || c.lastDiff.result !== diff.result) { - c.callback(c.lastDiff = diff); + if (!lastDiff || !equal(lastDiff.result, diff.result)) { + c.callback(c.lastDiff = diff, lastDiff); } } } diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index a87b4e30395..53485777b07 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -15,6 +15,9 @@ import { DefaultContext, OperationVariables, Resolvers, + RefetchQueriesOptions, + RefetchQueriesResult, + InternalRefetchQueriesResult, } from './types'; import { @@ -23,7 +26,6 @@ import { MutationOptions, SubscriptionOptions, WatchQueryFetchPolicy, - RefetchQueryDescription, } from './watchQueryOptions'; import { @@ -535,10 +537,31 @@ export class ApolloClient implements DataProxy { * active queries. * 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)); + public refetchQueries< + TCache extends ApolloCache = ApolloCache, + TResult = Promise>, + >( + options: RefetchQueriesOptions, + ): RefetchQueriesResult { + const map = this.queryManager.refetchQueries(options); + const queries: ObservableQuery[] = []; + const results: InternalRefetchQueriesResult[] = []; + + map.forEach((result, obsQuery) => { + queries.push(obsQuery); + results.push(result); + }); + + const result = Promise.all( + results as TResult[] + ) as RefetchQueriesResult; + + // In case you need the raw results immediately, without awaiting + // Promise.all(results): + result.queries = queries; + result.results = results; + + return result; } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index aabc1afd508..021d19fccf9 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -19,6 +19,8 @@ import { isNonEmptyArray, Concast, ConcastSourcesIterable, + makeUniqueId, + isDocumentNode, } from '../utilities'; import { ApolloError, isApolloError } from '../errors'; import { @@ -28,7 +30,6 @@ import { MutationOptions, WatchQueryFetchPolicy, ErrorPolicy, - RefetchQueryDescription, } from './watchQueryOptions'; import { ObservableQuery } from './ObservableQuery'; import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; @@ -36,7 +37,12 @@ import { ApolloQueryResult, OperationVariables, MutationUpdaterFunction, - ReobserveQueryCallback, + OnQueryUpdated, + RefetchQueryDescription, + InternalRefetchQueriesOptions, + RefetchQueryDescriptor, + InternalRefetchQueriesResult, + InternalRefetchQueriesMap, } from './types'; import { LocalState } from './LocalState'; @@ -131,7 +137,12 @@ export class QueryManager { this.fetchCancelFns.clear(); } - public async mutate>({ + public async mutate< + TData, + TVariables, + TContext, + TCache extends ApolloCache + >({ mutation, variables, optimisticResponse, @@ -139,7 +150,7 @@ export class QueryManager { refetchQueries = [], awaitRefetchQueries = false, update: updateWithProxyFn, - reobserveQuery, + onQueryUpdated, errorPolicy = 'none', fetchPolicy, context, @@ -194,8 +205,6 @@ export class QueryManager { const self = this; return new Promise((resolve, reject) => { - let storeResult: FetchResult | null; - return asyncMap( self.getObservableFromLink( mutation, @@ -219,36 +228,73 @@ 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, - errorPolicy, - context, - updateQueries, - update: updateWithProxyFn, - reobserveQuery, - }); - } 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 results: any[] = []; + + this.refetchQueries({ + include: refetchQueries, + onQueryUpdated, + }).forEach(result => results.push(result)); + + return Promise.all( + awaitRefetchQueries ? results : [], + ).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) { + 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; @@ -267,41 +313,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; @@ -311,7 +332,9 @@ export class QueryManager { context?: TContext; updateQueries: UpdateQueries; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + refetchQueries?: RefetchQueryDescription; + removeOptimistic?: string; + onQueryUpdated?: OnQueryUpdated; }, cache = this.cache, ): Promise { @@ -362,39 +385,42 @@ export class QueryManager { }); } - const reobserveResults: any[] = []; + const results: any[] = []; + + 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, - 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; - } - } - }), - }); + // Remove the corresponding optimistic layer at the same time as we + // write the final non-optimistic result. + removeOptimistic: mutation.removeOptimistic, + + // Let the caller of client.mutate optionally determine the refetching + // behavior for watched queries after the mutation.update function runs. + // If no onQueryUpdated function was provided for this mutation, pass + // null instead of undefined to disable the default refetching behavior. + onQueryUpdated: mutation.onQueryUpdated || null, + + }).forEach(result => results.push(result)); - return Promise.all(reobserveResults).then(() => void 0); + return Promise.all(results).then(() => void 0); } return Promise.resolve(); @@ -560,6 +586,7 @@ export class QueryManager { public query( options: QueryOptions, + queryId = this.generateQueryId(), ): Promise> { invariant( options.query, @@ -582,7 +609,6 @@ export class QueryManager { 'pollInterval option only supported on watchQuery.', ); - const queryId = this.generateQueryId(); return this.fetchQuery( queryId, options, @@ -1016,38 +1042,183 @@ 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 ? makeUniqueId("refetchQueries") : void 0, + onQueryUpdated, + }: InternalRefetchQueriesOptions, TResult> + ): InternalRefetchQueriesMap { + const includedQueriesById = new Map; + diff?: Cache.DiffResult; + }>(); + + if (include) { + include.forEach(desc => { + getQueryIdsForQueryDescriptor(this, desc).forEach(queryId => { + includedQueriesById.set(queryId, { + desc, + lastDiff: typeof desc === "string" || isDocumentNode(desc) + ? this.getQuery(queryId).getDiff() + : void 0, }); - } else { - const queryOptions: QueryOptions = { - query: refetchQuery.query, - variables: refetchQuery.variables, - fetchPolicy: 'network-only', - }; - - if (refetchQuery.context) { - queryOptions.context = refetchQuery.context; + }); + }); + } + + const results: InternalRefetchQueriesMap = new Map; + + if (updateCache) { + this.cache.batch({ + update: updateCache, + + // Since you can perform any combination of cache reads and/or writes in + // the cache.batch update 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 updateCache function, 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(watch, diff, lastDiff) { + const oq = + watch.watcher instanceof QueryInfo && + watch.watcher.observableQuery; + + if (oq) { + if (onQueryUpdated) { + // Since we're about to handle this query now, remove it from + // includedQueriesById, in case it was added earlier because of + // options.include. + includedQueriesById.delete(oq.queryId); + + let result: boolean | InternalRefetchQueriesResult = + onQueryUpdated(oq, diff, lastDiff); + + if (result === true) { + // The onQueryUpdated function requested the default refetching + // behavior by returning true. + result = oq.refetch(); + } + + // Record the result in the results Map, as long as onQueryUpdated + // did not return false to skip/ignore this result. + if (result !== false) { + results.set(oq, result); + } + + // Prevent the normal cache broadcast of this result, since we've + // already handled it. + return false; + } + + if (onQueryUpdated !== null) { + // If we don't have an onQueryUpdated function, and onQueryUpdated + // was not disabled by passing null, make sure this query is + // "included" like any other options.include-specified query. + includedQueriesById.set(oq.queryId, { + desc: oq.queryName || ``, + lastDiff, + diff, + }); + } } + }, + }); + } - refetchQueryPromises.push(this.query(queryOptions)); + if (includedQueriesById.size) { + includedQueriesById.forEach(({ desc, lastDiff, diff }, queryId) => { + const queryInfo = this.getQuery(queryId); + let oq = queryInfo.observableQuery; + let fallback: undefined | (() => Promise>); + + if (typeof desc === "string" || isDocumentNode(desc)) { + fallback = () => oq!.refetch(); + } else if (desc && typeof desc === "object") { + const options = { + ...desc, + fetchPolicy: "network-only", + } as QueryOptions; + + queryInfo.setObservableQuery(oq = new ObservableQuery({ + queryManager: this, + queryInfo, + options, + })); + + fallback = () => this.query(options, queryId); + } + + if (oq && fallback) { + let result: undefined | boolean | InternalRefetchQueriesResult; + // If onQueryUpdated is provided, we want to use it for all included + // queries, even the PureQueryOptions ones. Otherwise, we call the + // fallback function defined above. + if (onQueryUpdated) { + if (!diff) { + queryInfo.reset(); // Force queryInfo.getDiff() to read from cache. + diff = queryInfo.getDiff(); + } + result = onQueryUpdated(oq, diff, lastDiff); + } + if (!onQueryUpdated || result === true) { + result = fallback(); + } + if (result !== false) { + results.set(oq, result!); + } } }); } - return refetchQueryPromises; + if (removeOptimistic) { + // In case no updateCache callback was provided (so cache.batch was not + // called above, and thus did not already remove the optimistic layer), + // remove it here. Since this is a no-op when the layer has already been + // removed, we do it even if we called cache.batch above, since it's + // possible this.cache is an instance of some ApolloCache subclass other + // than InMemoryCache, and does not fully support the removeOptimistic + // option for cache.batch. + this.cache.removeOptimistic(removeOptimistic); + } + + return results; } private fetchQueryByPolicy( @@ -1219,3 +1390,30 @@ export class QueryManager { }; } } + +function getQueryIdsForQueryDescriptor( + qm: QueryManager, + desc: RefetchQueryDescriptor, +) { + const queryIds: string[] = []; + const isName = typeof desc === "string"; + if (isName || isDocumentNode(desc)) { + qm["queries"].forEach(({ observableQuery: oq, document }, queryId) => { + if (oq && + desc === (isName ? oq.queryName : document) && + oq.hasObservers()) { + queryIds.push(queryId); + } + }); + } else { + // We will be issuing a fresh network request for this query, so we + // pre-allocate a new query ID here. + queryIds.push(qm.generateQueryId()); + } + if (process.env.NODE_ENV !== "production" && !queryIds.length) { + invariant.warn(`Unknown query name ${ + JSON.stringify(desc) + } passed to refetchQueries method in options.include array`); + } + return queryIds; +} diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 423f13cf63d..7caa643e226 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1953,11 +1953,11 @@ describe('ObservableQuery', () => { }); let invalidateCount = 0; - let onDirtyCount = 0; + let onWatchUpdatedCount = 0; cache.batch({ optimistic: true, - transaction(cache) { + update(cache) { cache.modify({ fields: { people_one(value, { INVALIDATE }) { @@ -1969,7 +1969,7 @@ describe('ObservableQuery', () => { }); }, // Verify that the cache.modify operation did trigger a cache broadcast. - onDirty(watch, diff) { + onWatchUpdated(watch, diff) { expect(watch.watcher).toBe(queryInfo); expect(diff).toEqual({ complete: true, @@ -1979,7 +1979,7 @@ describe('ObservableQuery', () => { }, }, }); - ++onDirtyCount; + ++onWatchUpdatedCount; }, }); @@ -1987,7 +1987,7 @@ describe('ObservableQuery', () => { expect(setDiffSpy).toHaveBeenCalledTimes(1); expect(notifySpy).not.toHaveBeenCalled(); expect(invalidateCount).toBe(1); - expect(onDirtyCount).toBe(1); + expect(onWatchUpdatedCount).toBe(1); queryManager.stop(); }).then(resolve, reject); } else { diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index d869425b033..56b7bc397c1 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4382,7 +4382,12 @@ describe('QueryManager', () => { observable.subscribe({ next: () => null }); observable2.subscribe({ next: () => null }); - return Promise.all(queryManager.refetchQueries(['GetAuthor', 'GetAuthor2'])).then(() => { + const results: any[] = []; + queryManager.refetchQueries({ + include: ['GetAuthor', 'GetAuthor2'], + }).forEach(result => results.push(result)); + + return Promise.all(results).then(() => { const result = getCurrentQueryResult(observable); expect(result.partial).toBe(false); expect(stripSymbols(result.data)).toEqual(dataChanged); @@ -4624,16 +4629,12 @@ describe('QueryManager', () => { }); describe('refetchQueries', () => { - const oldWarn = console.warn; - let timesWarned = 0; - + let consoleWarnSpy: jest.SpyInstance; beforeEach(() => { - // clear warnings - timesWarned = 0; - // mock warn method - console.warn = (...args: any[]) => { - timesWarned++; - }; + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + }); + afterEach(() => { + consoleWarnSpy.mockRestore(); }); itAsync('should refetch the right query when a result is successfully returned', (resolve, reject) => { @@ -4772,12 +4773,15 @@ describe('QueryManager', () => { }, result => { expect(stripSymbols(result.data)).toEqual(secondReqData); - expect(timesWarned).toBe(0); + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + 'Unknown query name "fakeQuery" passed to refetchQueries method ' + + "in options.include array" + ); }, ).then(resolve, reject); }); - itAsync('should ignore without warning a query name that is asked to refetch with no active subscriptions', (resolve, reject) => { + itAsync('should ignore (with warning) a query named in refetchQueries that has no active subscriptions', (resolve, reject) => { const mutation = gql` mutation changeAuthorName { changeAuthorName(newName: "Jack Smith") { @@ -4831,16 +4835,18 @@ describe('QueryManager', () => { const observable = queryManager.watchQuery({ query }); return observableToPromise({ observable }, result => { expect(stripSymbols(result.data)).toEqual(data); - }) - .then(() => { - // The subscription has been stopped already - return queryManager.mutate({ - mutation, - refetchQueries: ['getAuthors'], - }); - }) - .then(() => expect(timesWarned).toBe(0)) - .then(resolve, reject); + }).then(() => { + // The subscription has been stopped already + return queryManager.mutate({ + mutation, + refetchQueries: ['getAuthors'], + }); + }).then(() => { + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + 'Unknown query name "getAuthors" passed to refetchQueries method ' + + "in options.include array" + ); + }).then(resolve, reject); }); itAsync('also works with a query document and variables', (resolve, reject) => { @@ -5221,13 +5227,9 @@ describe('QueryManager', () => { }, ).then(resolve, reject); }); - - afterEach(() => { - console.warn = oldWarn; - }); }); - describe('reobserveQuery', () => { + describe('onQueryUpdated', () => { const mutation = gql` mutation changeAuthorName { changeAuthorName(newName: "Jack Smith") { @@ -5316,13 +5318,14 @@ describe('QueryManager', () => { }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); - return obsQuery.refetch().then(async () => { + return obsQuery.refetch().then(async (result) => { // Wait a bit to make sure the mutation really awaited the // refetching of the query. await new Promise(resolve => setTimeout(resolve, 100)); finishedRefetch = true; + return result; }); }, }).then(() => { @@ -5374,7 +5377,7 @@ describe('QueryManager', () => { }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); return obsQuery.refetch(); }, @@ -5415,7 +5418,7 @@ describe('QueryManager', () => { cache.evict({ fieldName: "author" }); }, - reobserveQuery(obsQuery) { + onQueryUpdated(obsQuery) { expect(obsQuery.options.query).toBe(query); return obsQuery.reobserve({ fetchPolicy: "network-only", diff --git a/src/core/types.ts b/src/core/types.ts index c6e40a3eb31..a6907179a34 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -8,6 +8,7 @@ import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; import { ObservableQuery } from './ObservableQuery'; import { Cache } from '../cache'; +import { IsStrictlyAny } from '../utilities'; export { TypedDocumentNode } from '@graphql-typed-document-node/core'; @@ -15,10 +16,97 @@ export type DefaultContext = Record; export type QueryListener = (queryInfo: QueryInfo) => void; -export type ReobserveQueryCallback = ( - observableQuery: ObservableQuery, +export type OnQueryUpdated = ( + observableQuery: ObservableQuery, diff: Cache.DiffResult, -) => void | Promise; + lastDiff: Cache.DiffResult | undefined, +) => boolean | TResult; + +export type RefetchQueryDescriptor = string | DocumentNode | PureQueryOptions; +export type RefetchQueryDescription = RefetchQueryDescriptor[]; + +// Used by ApolloClient["refetchQueries"] +// TODO Improve documentation comments for this public type. +export interface RefetchQueriesOptions< + TCache extends ApolloCache, + TResult, +> { + updateCache?: (cache: TCache) => void; + // Although you can pass PureQueryOptions objects in addition to strings in + // the refetchQueries array for a mutation, the client.refetchQueries method + // deliberately discourages passing PureQueryOptions, by restricting the + // public type of the options.include array to string[] (just query names). + include?: Exclude[]; + optimistic?: boolean; + // If no onQueryUpdated function is provided, any queries affected by the + // updateCache function or included in the options.include array will be + // refetched by default. Passing null instead of undefined disables this + // default refetching behavior for affected queries, though included queries + // will still be refetched. + onQueryUpdated?: OnQueryUpdated | null; +} + +// The client.refetchQueries method returns a thenable (PromiseLike) object +// whose result is an array of Promise.resolve'd TResult values, where TResult +// is whatever type the (optional) onQueryUpdated function returns. When no +// onQueryUpdated function is given, TResult defaults to ApolloQueryResult +// (thanks to default type parameters for client.refetchQueries). +export type RefetchQueriesPromiseResults = + // If onQueryUpdated returns any, all bets are off, so the results array must + // be a generic any[] array, which is much less confusing than the union type + // we get if we don't check for any. I hoped `any extends TResult` would do + // the trick here, instead of IsStrictlyAny, but you can see for yourself what + // fails in the refetchQueries tests if you try making that simplification. + IsStrictlyAny extends true ? any[] : + // If the onQueryUpdated function passed to client.refetchQueries returns true + // or false, that means either to refetch the query (true) or to skip the + // query (false). Since refetching produces an ApolloQueryResult, and + // skipping produces nothing, the fully-resolved array of all results produced + // will be an ApolloQueryResult[], when TResult extends boolean. + TResult extends boolean ? ApolloQueryResult[] : + // If onQueryUpdated returns a PromiseLike, that thenable will be passed as + // an array element to Promise.all, so we infer/unwrap the array type U here. + TResult extends PromiseLike ? U[] : + // All other onQueryUpdated results end up in the final Promise.all array as + // themselves, with their original TResult type. Note that TResult will + // default to ApolloQueryResult if no onQueryUpdated function is passed + // to client.refetchQueries. + TResult[]; + +// The result of client.refetchQueries is thenable/awaitable, if you just want +// an array of fully resolved results, but you can also access the raw results +// immediately by examining the additional { queries, results } properties of +// the RefetchQueriesResult object. +export interface RefetchQueriesResult +extends Promise> { + // An array of ObservableQuery objects corresponding 1:1 to TResult values + // in the results arrays (both the TResult[] array below, and the results + // array resolved by the Promise above). + queries: ObservableQuery[]; + // These are the raw TResult values returned by any onQueryUpdated functions + // that were invoked by client.refetchQueries. + results: InternalRefetchQueriesResult[]; +} + +// Used by QueryManager["refetchQueries"] +export interface InternalRefetchQueriesOptions< + TCache extends ApolloCache, + TResult, +> extends Omit, "include"> { + // Just like the refetchQueries array for a mutation, allowing both strings + // and PureQueryOptions objects. + include?: RefetchQueryDescription; + // This part of the API is a (useful) implementation detail, but need not be + // exposed in the public client.refetchQueries API (above). + removeOptimistic?: string; +} + +export type InternalRefetchQueriesResult = + TResult | Promise>; + +export type InternalRefetchQueriesMap = + Map, + InternalRefetchQueriesResult>; export type OperationVariables = Record; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index beca5cce0cc..ed958668540 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -5,10 +5,10 @@ import { FetchResult } from '../link/core'; import { DefaultContext, MutationQueryReducersMap, - PureQueryOptions, OperationVariables, MutationUpdaterFunction, - ReobserveQueryCallback, + OnQueryUpdated, + RefetchQueryDescription, } from './types'; import { ApolloCache } from '../cache'; @@ -189,8 +189,6 @@ export interface SubscriptionOptions; - export interface MutationBaseOptions< TData = any, TVariables = OperationVariables, @@ -260,7 +258,7 @@ export interface MutationBaseOptions< * A function that will be called for each ObservableQuery affected by * this mutation, after the mutation has completed. */ - reobserveQuery?: ReobserveQueryCallback; + onQueryUpdated?: OnQueryUpdated; /** * 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 f3f938e50c6..01f51941862 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -747,7 +747,7 @@ describe('useMutation Hook', () => { }); describe('refetching queries', () => { - itAsync('can pass reobserveQuery to useMutation', (resolve, reject) => { + itAsync('can pass onQueryUpdated to useMutation', (resolve, reject) => { interface TData { todoCount: number; } @@ -791,17 +791,17 @@ describe('useMutation Hook', () => { }).setOnError(reject), }); - // The goal of this test is to make sure reobserveQuery gets called as + // The goal of this test is to make sure onQueryUpdated gets called as // part of the createTodo mutation, so we use this reobservePromise to - // await the calling of reobserveQuery. - interface ReobserveResults { + // await the calling of onQueryUpdated. + interface OnQueryUpdatedResults { obsQuery: ObservableQuery; diff: Cache.DiffResult; result: ApolloQueryResult; } - let reobserveResolve: (results: ReobserveResults) => any; - const reobservePromise = new Promise(resolve => { - reobserveResolve = resolve; + let resolveOnUpdate: (results: OnQueryUpdatedResults) => any; + const onUpdatePromise = new Promise(resolve => { + resolveOnUpdate = resolve; }); let finishedReobserving = false; @@ -838,10 +838,11 @@ describe('useMutation Hook', () => { act(() => { createTodo({ variables, - reobserveQuery(obsQuery, diff) { + onQueryUpdated(obsQuery, diff) { return obsQuery.reobserve().then(result => { finishedReobserving = true; - reobserveResolve({ obsQuery, diff, result }); + resolveOnUpdate({ obsQuery, diff, result }); + return result; }); }, }); @@ -888,7 +889,7 @@ describe('useMutation Hook', () => { ); - return reobservePromise.then(results => { + return onUpdatePromise.then(results => { expect(finishedReobserving).toBe(true); expect(results.diff).toEqual({ diff --git a/src/react/types/types.ts b/src/react/types/types.ts index a7c692ba6f2..9d165d1e97f 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -19,7 +19,7 @@ import { ObservableQuery, OperationVariables, PureQueryOptions, - ReobserveQueryCallback, + OnQueryUpdated, WatchQueryFetchPolicy, WatchQueryOptions, } from '../../core'; @@ -154,7 +154,11 @@ export interface BaseMutationOptions< awaitRefetchQueries?: boolean; errorPolicy?: ErrorPolicy; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + // Use OnQueryUpdated instead of OnQueryUpdated here because TData + // is the shape of the mutation result, but onQueryUpdated gets called with + // results from any queries affected by the mutation update function, which + // probably do not have the same shape as the mutation result. + onQueryUpdated?: OnQueryUpdated; client?: ApolloClient; notifyOnNetworkStatusChange?: boolean; context?: TContext; @@ -175,7 +179,11 @@ export interface MutationFunctionOptions< refetchQueries?: Array | RefetchQueriesFunction; awaitRefetchQueries?: boolean; update?: MutationUpdaterFunction; - reobserveQuery?: ReobserveQueryCallback; + // Use OnQueryUpdated instead of OnQueryUpdated here because TData + // is the shape of the mutation result, but onQueryUpdated gets called with + // results from any queries affected by the mutation update function, which + // probably do not have the same shape as the mutation result. + onQueryUpdated?: OnQueryUpdated; context?: TContext; fetchPolicy?: WatchQueryFetchPolicy; } diff --git a/src/utilities/common/makeUniqueId.ts b/src/utilities/common/makeUniqueId.ts new file mode 100644 index 00000000000..b0e804bd7fb --- /dev/null +++ b/src/utilities/common/makeUniqueId.ts @@ -0,0 +1,9 @@ +const prefixCounts = new Map(); + +// These IDs won't be globally unique, but they will be unique within this +// process, thanks to the counter, and unguessable thanks to the random suffix. +export function makeUniqueId(prefix: string) { + const count = prefixCounts.get(prefix) || 1; + prefixCounts.set(prefix, count + 1); + return `${prefix}:${count}:${Math.random().toString(36).slice(2)}`; +} diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 70e0bc0eb5b..a0aa5e8d2d1 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -15,6 +15,7 @@ import { SelectionNode, NameNode, SelectionSetNode, + DocumentNode, } from 'graphql'; import { InvariantError } from 'ts-invariant'; @@ -48,6 +49,15 @@ export interface StoreObject { [storeFieldName: string]: StoreValue; } +export function isDocumentNode(value: any): value is DocumentNode { + return ( + value !== null && + typeof value === "object" && + (value as DocumentNode).kind === "Document" && + Array.isArray((value as DocumentNode).definitions) + ); +} + function isStringValue(value: ValueNode): value is StringValueNode { return value.kind === 'StringValue'; } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 7617a9187b3..c878b83622b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -33,6 +33,7 @@ export { Directives, VariableValue, makeReference, + isDocumentNode, isReference, isField, isInlineFragment, @@ -86,3 +87,6 @@ export * from './common/arrays'; export * from './common/errorHandling'; export * from './common/canUse'; export * from './common/compact'; +export * from './common/makeUniqueId'; + +export * from './types/IsStrictlyAny'; diff --git a/src/utilities/types/IsStrictlyAny.ts b/src/utilities/types/IsStrictlyAny.ts new file mode 100644 index 00000000000..6607dac5945 --- /dev/null +++ b/src/utilities/types/IsStrictlyAny.ts @@ -0,0 +1,17 @@ +// Returns true if T is any, or false for any other type. +// Inspired by https://stackoverflow.com/a/61625296/128454. +export type IsStrictlyAny = + UnionToIntersection> extends never ? true : false; + +// If (and only if) T is any, the union 'a' | 1 is returned here, representing +// both branches of this conditional type. Only UnionForAny produces this +// union type; all other inputs produce the 1 literal type. +type UnionForAny = T extends never ? 'a' : 1; + +// If that 'a' | 1 union is then passed to UnionToIntersection, the result +// should be 'a' & 1, which TypeScript simplifies to the never type, since the +// literal type 'a' and the literal type 1 are incompatible. More explanation of +// this helper type: https://stackoverflow.com/a/50375286/62076. +type UnionToIntersection = + (U extends any ? (k: U) => void : never) extends + ((k: infer I) => void) ? I : never