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