diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e4c5bda4c7..dc2b65dc9b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,9 @@
- The `cache.evict` method can optionally take an arguments object as its third parameter (following the entity ID and field name), to delete only those field values with specific arguments.
[@danReynolds](https://github.com/danReynolds) in [#6141](https://github.com/apollographql/apollo-client/pull/6141)
+- Cache methods that would normally trigger a broadcast, like `cache.evict`, `cache.writeQuery`, and `cache.writeFragment`, can now be called with a named options object, which supports a `broadcast: boolean` property that can be used to silence the broadcast, for situations where you want to update the cache multiple times without triggering a broadcast each time.
+ [@benjamn](https://github.com/benjamn) in [#6288](https://github.com/apollographql/apollo-client/pull/6288)
+
- The contents of the `@apollo/react-hooks` package have been merged into `@apollo/client`, enabling the following all-in-one `import`:
```ts
import { ApolloClient, ApolloProvider, useQuery } from '@apollo/client';
diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts
index 1cfeae93845..04b3cdb6bfe 100644
--- a/src/cache/core/__tests__/cache.ts
+++ b/src/cache/core/__tests__/cache.ts
@@ -11,7 +11,7 @@ class TestCache extends ApolloCache {
return {};
}
- public evict(dataId: string, fieldName?: string): boolean {
+ public evict(): boolean {
return false;
}
diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts
index 581653b8e7f..ff0415d9b73 100644
--- a/src/cache/core/cache.ts
+++ b/src/cache/core/cache.ts
@@ -22,10 +22,20 @@ export abstract class ApolloCache implements DataProxy {
public abstract watch(watch: Cache.WatchOptions): () => void;
public abstract reset(): Promise;
- // If called with only one argument, removes the entire entity
- // identified by dataId. If called with a fieldName as well, removes all
- // fields of the identified entity whose store names match fieldName.
- public abstract evict(dataId: string, fieldName?: string): boolean;
+ // Remove whole objects from the cache by passing just options.id, or
+ // specific fields by passing options.field and/or options.args. If no
+ // options.args are provided, all fields matching options.field (even
+ // those with arguments) will be removed. Returns true iff any data was
+ // removed from the cache.
+ public abstract evict(options: Cache.EvictOptions): boolean;
+
+ // For backwards compatibility, evict can also take positional
+ // arguments. Please prefer the Cache.EvictOptions style (above).
+ public abstract evict(
+ id: string,
+ field?: string,
+ args?: Record,
+ ): boolean;
// intializer / offline / ssr API
/**
@@ -129,6 +139,7 @@ export abstract class ApolloCache implements DataProxy {
result: options.data,
query: options.query,
variables: options.variables,
+ broadcast: options.broadcast,
});
}
@@ -140,6 +151,7 @@ export abstract class ApolloCache implements DataProxy {
result: options.data,
variables: options.variables,
query: this.getFragmentDoc(options.fragment, options.fragmentName),
+ broadcast: options.broadcast,
});
}
}
diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts
index 41bab402bab..55d7e4279c8 100644
--- a/src/cache/core/types/Cache.ts
+++ b/src/cache/core/types/Cache.ts
@@ -1,7 +1,7 @@
import { DataProxy } from './DataProxy';
export namespace Cache {
- export type WatchCallback = (newData: any) => void;
+ export type WatchCallback = (diff: Cache.DiffResult) => void;
export interface ReadOptions
extends DataProxy.Query {
@@ -14,6 +14,7 @@ export namespace Cache {
extends DataProxy.Query {
dataId: string;
result: TResult;
+ broadcast?: boolean;
}
export interface DiffOptions extends ReadOptions {
@@ -25,6 +26,13 @@ export namespace Cache {
callback: WatchCallback;
}
+ export interface EvictOptions {
+ id: string;
+ fieldName?: string;
+ args?: Record;
+ broadcast?: boolean;
+ }
+
export import DiffResult = DataProxy.DiffResult;
export import WriteQueryOptions = DataProxy.WriteQueryOptions;
export import WriteFragmentOptions = DataProxy.WriteFragmentOptions;
diff --git a/src/cache/core/types/DataProxy.ts b/src/cache/core/types/DataProxy.ts
index b20038e4bcd..d9da863b13c 100644
--- a/src/cache/core/types/DataProxy.ts
+++ b/src/cache/core/types/DataProxy.ts
@@ -59,6 +59,10 @@ export namespace DataProxy {
* The data you will be writing to the store.
*/
data: TData;
+ /**
+ * Whether to notify query watchers (default: true).
+ */
+ broadcast?: boolean;
}
export interface WriteFragmentOptions
@@ -67,6 +71,10 @@ export namespace DataProxy {
* The data you will be writing to the store.
*/
data: TData;
+ /**
+ * Whether to notify query watchers (default: true).
+ */
+ broadcast?: boolean;
}
export type DiffResult = {
diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts
index 26bd3f60938..4e20c5bda6d 100644
--- a/src/cache/inmemory/__tests__/entityStore.ts
+++ b/src/cache/inmemory/__tests__/entityStore.ts
@@ -925,13 +925,13 @@ describe('EntityStore', () => {
publisherOfBook: MelvilleData,
});
- cache.evict(
- cache.identify({
+ cache.evict({
+ id: cache.identify({
__typename: "Publisher",
name: "Alfred A. Knopf",
})!,
- "yearOfFounding",
- );
+ fieldName: "yearOfFounding",
+ });
expect(cache.extract()).toEqual({
ROOT_QUERY: {
@@ -951,10 +951,12 @@ describe('EntityStore', () => {
// Nothing to garbage collect yet.
expect(cache.gc()).toEqual([]);
- cache.evict(cache.identify({
- __typename: "Publisher",
- name: "Melville House",
- })!);
+ cache.evict({
+ id: cache.identify({
+ __typename: "Publisher",
+ name: "Melville House",
+ })!,
+ });
expect(cache.extract()).toEqual({
ROOT_QUERY: {
@@ -970,7 +972,7 @@ describe('EntityStore', () => {
// Melville House has been removed
});
- cache.evict("ROOT_QUERY", "publisherOfBook");
+ cache.evict({ id: "ROOT_QUERY", fieldName: "publisherOfBook" });
function withoutPublisherOfBook(obj: Record) {
const clean = { ...obj };
@@ -1049,10 +1051,10 @@ describe('EntityStore', () => {
name: "Ted Chiang",
};
- cache.evict(
- cache.identify(tedWithoutHobby)!,
- "hobby",
- );
+ cache.evict({
+ id: cache.identify(tedWithoutHobby)!,
+ fieldName: "hobby",
+ });
expect(cache.diff({
query,
@@ -1082,7 +1084,7 @@ describe('EntityStore', () => {
],
});
- cache.evict("ROOT_QUERY", "authorOfBook");
+ cache.evict({ id: "ROOT_QUERY", fieldName: "authorOfBook"});
expect(cache.gc().sort()).toEqual([
'Author:{"name":"Jenny Odell"}',
'Author:{"name":"Ted Chiang"}',
@@ -1232,6 +1234,158 @@ describe('EntityStore', () => {
});
});
+ it("allows evicting specific fields with specific arguments using EvictOptions", () => {
+ const query: DocumentNode = gql`
+ query {
+ authorOfBook(isbn: $isbn) {
+ name
+ hobby
+ }
+ }
+ `;
+
+ const cache = new InMemoryCache();
+
+ const TedChiangData = {
+ __typename: "Author",
+ name: "Ted Chiang",
+ hobby: "video games",
+ };
+
+ const IsaacAsimovData = {
+ __typename: "Author",
+ name: "Isaac Asimov",
+ hobby: "chemistry",
+ };
+
+ const JamesCoreyData = {
+ __typename: "Author",
+ name: "James S.A. Corey",
+ hobby: "tabletop games",
+ };
+
+ cache.writeQuery({
+ query,
+ data: {
+ authorOfBook: TedChiangData,
+ },
+ variables: {
+ isbn: "1",
+ },
+ });
+
+ cache.writeQuery({
+ query,
+ data: {
+ authorOfBook: IsaacAsimovData,
+ },
+ variables: {
+ isbn: "2",
+ },
+ });
+
+ cache.writeQuery({
+ query,
+ data: {
+ authorOfBook: JamesCoreyData,
+ },
+ variables: {},
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ "authorOfBook({\"isbn\":\"1\"})": {
+ __typename: "Author",
+ name: "Ted Chiang",
+ hobby: "video games",
+ },
+ "authorOfBook({\"isbn\":\"2\"})": {
+ __typename: "Author",
+ name: "Isaac Asimov",
+ hobby: "chemistry",
+ },
+ "authorOfBook({})": {
+ __typename: "Author",
+ name: "James S.A. Corey",
+ hobby: "tabletop games",
+ }
+ },
+ });
+
+ cache.evict({
+ id: 'ROOT_QUERY',
+ fieldName: 'authorOfBook',
+ args: { isbn: "1" },
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ "authorOfBook({\"isbn\":\"2\"})": {
+ __typename: "Author",
+ name: "Isaac Asimov",
+ hobby: "chemistry",
+ },
+ "authorOfBook({})": {
+ __typename: "Author",
+ name: "James S.A. Corey",
+ hobby: "tabletop games",
+ }
+ },
+ });
+
+ cache.evict({
+ id: 'ROOT_QUERY',
+ fieldName: 'authorOfBook',
+ args: { isbn: '3' },
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ "authorOfBook({\"isbn\":\"2\"})": {
+ __typename: "Author",
+ name: "Isaac Asimov",
+ hobby: "chemistry",
+ },
+ "authorOfBook({})": {
+ __typename: "Author",
+ name: "James S.A. Corey",
+ hobby: "tabletop games",
+ }
+ },
+ });
+
+ cache.evict({
+ id: 'ROOT_QUERY',
+ fieldName: 'authorOfBook',
+ args: {},
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ "authorOfBook({\"isbn\":\"2\"})": {
+ __typename: "Author",
+ name: "Isaac Asimov",
+ hobby: "chemistry",
+ },
+ },
+ });
+
+ cache.evict({
+ id: 'ROOT_QUERY',
+ fieldName: 'authorOfBook',
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ },
+ });
+ });
+
it("supports cache.identify(object)", () => {
const queryWithAliases: DocumentNode = gql`
query {
diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts
index b120144c787..1dbbef8d0d9 100644
--- a/src/cache/inmemory/__tests__/writeToStore.ts
+++ b/src/cache/inmemory/__tests__/writeToStore.ts
@@ -15,6 +15,7 @@ import {
} from '../../../utilities/graphql/storeUtils';
import { addTypenameToDocument } from '../../../utilities/graphql/transform';
import { cloneDeep } from '../../../utilities/common/cloneDeep';
+import { itAsync } from '../../../utilities/testing/itAsync';
import { StoreWriter } from '../writeToStore';
import { defaultNormalizedCacheFactory } from '../entityStore';
import { InMemoryCache } from '../inMemoryCache';
@@ -2085,4 +2086,122 @@ describe('writing to the store', () => {
expect(mergeCounts).toEqual({ first: 1, second: 1, third: 1, fourth: 1 });
});
+
+ itAsync("should allow silencing broadcast of cache updates", function (resolve, reject) {
+ const cache = new InMemoryCache({
+ typePolicies: {
+ Counter: {
+ // Counter is a singleton, but we want to be able to test
+ // writing to it with writeFragment, so it needs to have an ID.
+ keyFields: [],
+ },
+ },
+ });
+
+ const query = gql`
+ query {
+ counter {
+ count
+ }
+ }
+ `;
+
+ const results: number[] = [];
+
+ cache.watch({
+ query,
+ optimistic: true,
+ callback(diff) {
+ results.push(diff.result);
+ expect(diff.result).toEqual({
+ counter: {
+ __typename: "Counter",
+ count: 3,
+ },
+ });
+ resolve();
+ },
+ });
+
+ let count = 0;
+
+ cache.writeQuery({
+ query,
+ data: {
+ counter: {
+ __typename: "Counter",
+ count: ++count,
+ },
+ },
+ broadcast: false,
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ counter: { __ref: "Counter:{}" },
+ },
+ "Counter:{}": {
+ __typename: "Counter",
+ count: 1,
+ },
+ });
+
+ expect(results).toEqual([]);
+
+ const counterId = cache.identify({
+ __typename: "Counter",
+ })!;
+
+ cache.writeFragment({
+ id: counterId,
+ fragment: gql`fragment Count on Counter { count }`,
+ data: {
+ count: ++count,
+ },
+ broadcast: false,
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ counter: { __ref: "Counter:{}" },
+ },
+ "Counter:{}": {
+ __typename: "Counter",
+ count: 2,
+ },
+ });
+
+ expect(results).toEqual([]);
+
+ expect(cache.evict({
+ id: counterId,
+ fieldName: "count",
+ broadcast: false,
+ })).toBe(true);
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ __typename: "Query",
+ counter: { __ref: "Counter:{}" },
+ },
+ "Counter:{}": {
+ __typename: "Counter",
+ },
+ });
+
+ expect(results).toEqual([]);
+
+ // Only this write should trigger a broadcast.
+ cache.writeQuery({
+ query,
+ data: {
+ counter: {
+ __typename: "Counter",
+ count: 3,
+ },
+ },
+ });
+ });
});
diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts
index 2cb7ba74f2f..e7ac5564aa9 100644
--- a/src/cache/inmemory/entityStore.ts
+++ b/src/cache/inmemory/entityStore.ts
@@ -14,7 +14,8 @@ import { canUseWeakMap } from '../../utilities/common/canUse';
import { NormalizedCache, NormalizedCacheObject } from './types';
import { fieldNameFromStoreName } from './helpers';
import { Policies } from './policies';
-import { Modifier, Modifiers, SafeReadonly } from '../core/types/common';
+import { Modifier, Modifiers, SafeReadonly } from '../core/types/common';
+import { Cache } from '../core/types/Cache';
const hasOwn = Object.prototype.hasOwnProperty;
@@ -197,23 +198,19 @@ export abstract class EntityStore implements NormalizedCache {
return false;
}
- public evict(
- dataId: string,
- fieldName?: string,
- args?: Record,
- ): boolean {
+ public evict(options: Cache.EvictOptions): boolean {
let evicted = false;
- if (hasOwn.call(this.data, dataId)) {
- evicted = this.delete(dataId, fieldName, args);
+ if (hasOwn.call(this.data, options.id)) {
+ evicted = this.delete(options.id, options.fieldName, options.args);
}
if (this instanceof Layer) {
- evicted = this.parent.evict(dataId, fieldName, args) || evicted;
+ evicted = this.parent.evict(options) || evicted;
}
// Always invalidate the field to trigger rereading of watched
// queries, even if no cache data was modified by the eviction,
// because queries may depend on computed fields with custom read
// functions, whose values are not stored in the EntityStore.
- this.group.dirty(dataId, fieldName || "__exists");
+ this.group.dirty(options.id, options.fieldName || "__exists");
return evicted;
}
diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts
index c3b5e22c879..22d797c924c 100644
--- a/src/cache/inmemory/inMemoryCache.ts
+++ b/src/cache/inmemory/inMemoryCache.ts
@@ -146,7 +146,9 @@ export class InMemoryCache extends ApolloCache {
variables: options.variables,
});
- this.broadcastWatches();
+ if (options.broadcast !== false) {
+ this.broadcastWatches();
+ }
}
public modify(
@@ -224,12 +226,21 @@ export class InMemoryCache extends ApolloCache {
}
public evict(
- dataId: string,
+ idOrOptions: string | Cache.EvictOptions,
fieldName?: string,
args?: Record,
): boolean {
- const evicted = this.optimisticData.evict(dataId, fieldName, args);
- this.broadcastWatches();
+ const evicted = this.optimisticData.evict(
+ typeof idOrOptions === "string" ? {
+ id: idOrOptions,
+ fieldName,
+ args,
+ } : idOrOptions,
+ );
+ if (typeof idOrOptions === "string" ||
+ idOrOptions.broadcast !== false) {
+ this.broadcastWatches();
+ }
return evicted;
}